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)