istio.io/istio@v0.0.0-20240520182934-d79c90f27776/samples/bookinfo/src/productpage/productpage.py (about) 1 #!/usr/bin/python 2 # 3 # Copyright Istio Authors 4 # 5 # Licensed under the Apache License, Version 2.0 (the "License"); 6 # you may not use this file except in compliance with the License. 7 # You may obtain a copy of the License at 8 # 9 # http://www.apache.org/licenses/LICENSE-2.0 10 # 11 # Unless required by applicable law or agreed to in writing, software 12 # distributed under the License is distributed on an "AS IS" BASIS, 13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 # See the License for the specific language governing permissions and 15 # limitations under the License. 16 17 18 from flask import Flask, request, session, render_template, redirect 19 from flask_bootstrap import Bootstrap 20 from json2html import json2html 21 from opentelemetry import trace 22 from opentelemetry.instrumentation.flask import FlaskInstrumentor 23 from opentelemetry.propagate import set_global_textmap 24 from opentelemetry.propagators.b3 import B3MultiFormat 25 from opentelemetry.sdk.trace import TracerProvider 26 from prometheus_client import Counter, generate_latest 27 import asyncio 28 import logging 29 import os 30 import requests 31 import simplejson as json 32 import sys 33 34 35 # These two lines enable debugging at httplib level (requests->urllib3->http.client) 36 # You will see the REQUEST, including HEADERS and DATA, and RESPONSE with HEADERS but without DATA. 37 # The only thing missing will be the response.body which is not logged. 38 import http.client as http_client 39 http_client.HTTPConnection.debuglevel = 1 40 41 app = Flask(__name__) 42 FlaskInstrumentor().instrument_app(app) 43 logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) 44 requests_log = logging.getLogger("requests.packages.urllib3") 45 requests_log.setLevel(logging.DEBUG) 46 requests_log.propagate = True 47 app.logger.addHandler(logging.StreamHandler(sys.stdout)) 48 app.logger.setLevel(logging.DEBUG) 49 50 # Set the secret key to some random bytes. Keep this really secret! 51 app.secret_key = b'_5#y2L"F4Q8z\n\xec]/' 52 53 Bootstrap(app) 54 55 servicesDomain = "" if (os.environ.get("SERVICES_DOMAIN") is None) else "." + os.environ.get("SERVICES_DOMAIN") 56 detailsHostname = "details" if (os.environ.get("DETAILS_HOSTNAME") is None) else os.environ.get("DETAILS_HOSTNAME") 57 detailsPort = "9080" if (os.environ.get("DETAILS_SERVICE_PORT") is None) else os.environ.get("DETAILS_SERVICE_PORT") 58 ratingsHostname = "ratings" if (os.environ.get("RATINGS_HOSTNAME") is None) else os.environ.get("RATINGS_HOSTNAME") 59 ratingsPort = "9080" if (os.environ.get("RATINGS_SERVICE_PORT") is None) else os.environ.get("RATINGS_SERVICE_PORT") 60 reviewsHostname = "reviews" if (os.environ.get("REVIEWS_HOSTNAME") is None) else os.environ.get("REVIEWS_HOSTNAME") 61 reviewsPort = "9080" if (os.environ.get("REVIEWS_SERVICE_PORT") is None) else os.environ.get("REVIEWS_SERVICE_PORT") 62 63 flood_factor = 0 if (os.environ.get("FLOOD_FACTOR") is None) else int(os.environ.get("FLOOD_FACTOR")) 64 65 details = { 66 "name": "http://{0}{1}:{2}".format(detailsHostname, servicesDomain, detailsPort), 67 "endpoint": "details", 68 "children": [] 69 } 70 71 ratings = { 72 "name": "http://{0}{1}:{2}".format(ratingsHostname, servicesDomain, ratingsPort), 73 "endpoint": "ratings", 74 "children": [] 75 } 76 77 reviews = { 78 "name": "http://{0}{1}:{2}".format(reviewsHostname, servicesDomain, reviewsPort), 79 "endpoint": "reviews", 80 "children": [ratings] 81 } 82 83 productpage = { 84 "name": "http://{0}{1}:{2}".format(detailsHostname, servicesDomain, detailsPort), 85 "endpoint": "details", 86 "children": [details, reviews] 87 } 88 89 service_dict = { 90 "productpage": productpage, 91 "details": details, 92 "reviews": reviews, 93 } 94 95 request_result_counter = Counter('request_result', 'Results of requests', ['destination_app', 'response_code']) 96 97 # A note on distributed tracing: 98 # 99 # Although Istio proxies are able to automatically send spans, they need some 100 # hints to tie together the entire trace. Applications need to propagate the 101 # appropriate HTTP headers so that when the proxies send span information, the 102 # spans can be correlated correctly into a single trace. 103 # 104 # To do this, an application needs to collect and propagate headers from the 105 # incoming request to any outgoing requests. The choice of headers to propagate 106 # is determined by the trace configuration used. See getForwardHeaders for 107 # the different header options. 108 # 109 # This example code uses OpenTelemetry (http://opentelemetry.io/) to propagate 110 # the 'b3' (zipkin) headers. Using OpenTelemetry for this is not a requirement. 111 # Using OpenTelemetry allows you to add application-specific tracing later on, 112 # but you can just manually forward the headers if you prefer. 113 # 114 # The OpenTelemetry example here is very basic. It only forwards headers. It is 115 # intended as a reference to help people get started, eg how to create spans, 116 # extract/inject context, etc. 117 118 119 propagator = B3MultiFormat() 120 set_global_textmap(B3MultiFormat()) 121 provider = TracerProvider() 122 # Sets the global default tracer provider 123 trace.set_tracer_provider(provider) 124 125 tracer = trace.get_tracer(__name__) 126 127 128 def getForwardHeaders(request): 129 headers = {} 130 131 # x-b3-*** headers can be populated using the OpenTelemetry span 132 ctx = propagator.extract(carrier={k.lower(): v for k, v in request.headers}) 133 propagator.inject(headers, ctx) 134 135 # We handle other (non x-b3-***) headers manually 136 if 'user' in session: 137 headers['end-user'] = session['user'] 138 139 # Keep this in sync with the headers in details and reviews. 140 incoming_headers = [ 141 # All applications should propagate x-request-id. This header is 142 # included in access log statements and is used for consistent trace 143 # sampling and log sampling decisions in Istio. 144 'x-request-id', 145 146 # Lightstep tracing header. Propagate this if you use lightstep tracing 147 # in Istio (see 148 # https://istio.io/latest/docs/tasks/observability/distributed-tracing/lightstep/) 149 # Note: this should probably be changed to use B3 or W3C TRACE_CONTEXT. 150 # Lightstep recommends using B3 or TRACE_CONTEXT and most application 151 # libraries from lightstep do not support x-ot-span-context. 152 'x-ot-span-context', 153 154 # Datadog tracing header. Propagate these headers if you use Datadog 155 # tracing. 156 'x-datadog-trace-id', 157 'x-datadog-parent-id', 158 'x-datadog-sampling-priority', 159 160 # W3C Trace Context. Compatible with OpenCensusAgent and Stackdriver Istio 161 # configurations. 162 'traceparent', 163 'tracestate', 164 165 # Cloud trace context. Compatible with OpenCensusAgent and Stackdriver Istio 166 # configurations. 167 'x-cloud-trace-context', 168 169 # Grpc binary trace context. Compatible with OpenCensusAgent nad 170 # Stackdriver Istio configurations. 171 'grpc-trace-bin', 172 173 # b3 trace headers. Compatible with Zipkin, OpenCensusAgent, and 174 # Stackdriver Istio configurations. 175 # This is handled by opentelemetry above 176 # 'x-b3-traceid', 177 # 'x-b3-spanid', 178 # 'x-b3-parentspanid', 179 # 'x-b3-sampled', 180 # 'x-b3-flags', 181 182 # SkyWalking trace headers. 183 'sw8', 184 185 # Application-specific headers to forward. 186 'user-agent', 187 188 # Context and session specific headers 189 'cookie', 190 'authorization', 191 'jwt', 192 ] 193 # For Zipkin, always propagate b3 headers. 194 # For Lightstep, always propagate the x-ot-span-context header. 195 # For Datadog, propagate the corresponding datadog headers. 196 # For OpenCensusAgent and Stackdriver configurations, you can choose any 197 # set of compatible headers to propagate within your application. For 198 # example, you can propagate b3 headers or W3C trace context headers with 199 # the same result. This can also allow you to translate between context 200 # propagation mechanisms between different applications. 201 202 for ihdr in incoming_headers: 203 val = request.headers.get(ihdr) 204 if val is not None: 205 headers[ihdr] = val 206 207 return headers 208 209 210 # The UI: 211 @app.route('/') 212 @app.route('/index.html') 213 def index(): 214 """ Display productpage with normal user and test user buttons""" 215 global productpage 216 217 table = json2html.convert(json=json.dumps(productpage), 218 table_attributes="class=\"table table-condensed table-bordered table-hover\"") 219 220 return render_template('index.html', serviceTable=table) 221 222 223 @app.route('/health') 224 def health(): 225 return 'Product page is healthy' 226 227 228 @app.route('/login', methods=['POST']) 229 def login(): 230 user = request.values.get('username') 231 response = app.make_response(redirect(request.referrer)) 232 session['user'] = user 233 return response 234 235 236 @app.route('/logout', methods=['GET']) 237 def logout(): 238 response = app.make_response(redirect(request.referrer)) 239 session.pop('user', None) 240 return response 241 242 # a helper function for asyncio.gather, does not return a value 243 244 245 async def getProductReviewsIgnoreResponse(product_id, headers): 246 getProductReviews(product_id, headers) 247 248 # flood reviews with unnecessary requests to demonstrate Istio rate limiting, asynchoronously 249 250 251 async def floodReviewsAsynchronously(product_id, headers): 252 # the response is disregarded 253 await asyncio.gather(*(getProductReviewsIgnoreResponse(product_id, headers) for _ in range(flood_factor))) 254 255 # flood reviews with unnecessary requests to demonstrate Istio rate limiting 256 257 258 def floodReviews(product_id, headers): 259 loop = asyncio.new_event_loop() 260 loop.run_until_complete(floodReviewsAsynchronously(product_id, headers)) 261 loop.close() 262 263 264 @app.route('/productpage') 265 def front(): 266 product_id = 0 # TODO: replace default value 267 headers = getForwardHeaders(request) 268 user = session.get('user', '') 269 product = getProduct(product_id) 270 detailsStatus, details = getProductDetails(product_id, headers) 271 272 if flood_factor > 0: 273 floodReviews(product_id, headers) 274 275 reviewsStatus, reviews = getProductReviews(product_id, headers) 276 return render_template( 277 'productpage.html', 278 detailsStatus=detailsStatus, 279 reviewsStatus=reviewsStatus, 280 product=product, 281 details=details, 282 reviews=reviews, 283 user=user) 284 285 286 # The API: 287 @app.route('/api/v1/products') 288 def productsRoute(): 289 return json.dumps(getProducts()), 200, {'Content-Type': 'application/json'} 290 291 292 @app.route('/api/v1/products/<product_id>') 293 def productRoute(product_id): 294 headers = getForwardHeaders(request) 295 status, details = getProductDetails(product_id, headers) 296 return json.dumps(details), status, {'Content-Type': 'application/json'} 297 298 299 @app.route('/api/v1/products/<product_id>/reviews') 300 def reviewsRoute(product_id): 301 headers = getForwardHeaders(request) 302 status, reviews = getProductReviews(product_id, headers) 303 return json.dumps(reviews), status, {'Content-Type': 'application/json'} 304 305 306 @app.route('/api/v1/products/<product_id>/ratings') 307 def ratingsRoute(product_id): 308 headers = getForwardHeaders(request) 309 status, ratings = getProductRatings(product_id, headers) 310 return json.dumps(ratings), status, {'Content-Type': 'application/json'} 311 312 313 @app.route('/metrics') 314 def metrics(): 315 return generate_latest() 316 317 318 # Data providers: 319 def getProducts(): 320 return [ 321 { 322 'id': 0, 323 'title': 'The Comedy of Errors', 324 'descriptionHtml': '<a href="https://en.wikipedia.org/wiki/The_Comedy_of_Errors">Wikipedia Summary</a>: The Comedy of Errors is one of <b>William Shakespeare\'s</b> early plays. It is his shortest and one of his most farcical comedies, with a major part of the humour coming from slapstick and mistaken identity, in addition to puns and word play.' 325 } 326 ] 327 328 329 def getProduct(product_id): 330 products = getProducts() 331 if product_id + 1 > len(products): 332 return None 333 else: 334 return products[product_id] 335 336 337 def getProductDetails(product_id, headers): 338 try: 339 url = details['name'] + "/" + details['endpoint'] + "/" + str(product_id) 340 res = requests.get(url, headers=headers, timeout=3.0) 341 except BaseException: 342 res = None 343 if res and res.status_code == 200: 344 request_result_counter.labels(destination_app='details', response_code=200).inc() 345 return 200, res.json() 346 else: 347 status = res.status_code if res is not None and res.status_code else 500 348 request_result_counter.labels(destination_app='details', response_code=status).inc() 349 return status, {'error': 'Sorry, product details are currently unavailable for this book.'} 350 351 352 def getProductReviews(product_id, headers): 353 # Do not remove. Bug introduced explicitly for illustration in fault injection task 354 # TODO: Figure out how to achieve the same effect using Envoy retries/timeouts 355 for _ in range(2): 356 try: 357 url = reviews['name'] + "/" + reviews['endpoint'] + "/" + str(product_id) 358 res = requests.get(url, headers=headers, timeout=3.0) 359 except BaseException: 360 res = None 361 if res and res.status_code == 200: 362 request_result_counter.labels(destination_app='reviews', response_code=200).inc() 363 return 200, res.json() 364 status = res.status_code if res is not None and res.status_code else 500 365 request_result_counter.labels(destination_app='reviews', response_code=status).inc() 366 return status, {'error': 'Sorry, product reviews are currently unavailable for this book.'} 367 368 369 def getProductRatings(product_id, headers): 370 try: 371 url = ratings['name'] + "/" + ratings['endpoint'] + "/" + str(product_id) 372 res = requests.get(url, headers=headers, timeout=3.0) 373 except BaseException: 374 res = None 375 if res and res.status_code == 200: 376 request_result_counter.labels(destination_app='ratings', response_code=200).inc() 377 return 200, res.json() 378 else: 379 status = res.status_code if res is not None and res.status_code else 500 380 request_result_counter.labels(destination_app='ratings', response_code=status).inc() 381 return status, {'error': 'Sorry, product ratings are currently unavailable for this book.'} 382 383 384 class Writer(object): 385 def __init__(self, filename): 386 self.file = open(filename, 'w') 387 388 def write(self, data): 389 self.file.write(data) 390 391 def flush(self): 392 self.file.flush() 393 394 395 if __name__ == '__main__': 396 if len(sys.argv) < 2: 397 logging.error("usage: %s port" % (sys.argv[0])) 398 sys.exit(-1) 399 400 p = int(sys.argv[1]) 401 logging.info("start at port %s" % (p)) 402 # Make it compatible with IPv6 if Linux 403 if sys.platform == "linux": 404 app.run(host='::', port=p, debug=True, threaded=True) 405 else: 406 app.run(host='0.0.0.0', port=p, debug=True, threaded=True)