FastAPI Observability Demo Application

repository·main·Indexed 22 days ago

https://github.com/blueswen/fastapi-observability

A demonstration project for implementing the three pillars of observability—Metrics, Traces, and Logs—in a FastAPI application. It utilizes the Grafana stack (Prometheus, Tempo, and Loki) and OpenTelemetry to showcase distributed tracing, Prometheus exemplars for trace linking, and log correlation.

Tokens
4.8K
Snippets
16
Records
17
Agent score
77%

What's inside fastapi-observability

  1. Propagate Trace IDs between services

    main

    FastAPI instrumentation only covers the ASGI app's request/response cycle. To maintain a single trace across multiple services when making outbound HTTP calls, you must manually inject the current span context into the request headers using opentelemetry.propagate.inject or use the HTTPXClientInstrumentor to automate the process.

    # Manual injection using inject()
    from opentelemetry.propagate import inject
    
    @app.get("/chain")
    async def chain(response: Response):
        headers = {}
        inject(headers)  # inject trace info to header
        async with httpx.AsyncClient() as client:
            await client.get(f"http://other-service:8000/", headers=headers)
    
    # Automatic injection using HTTPX instrumentation
    import httpx
    from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
    
    HTTPXClientInstrumentor().instrument()
    
    @app.get("/chain")
    async def chain(response: Response):
        async with httpx.AsyncClient() as client:
            await client.get(f"http://other-service:8000/") # Headers injected automatically
  2. Correlate Observability Data in Grafana

    main

    The project demonstrates how to navigate between the three pillars of observability using Trace IDs and exemplars in Grafana:

    • Metrics to Traces: Identify a specific request by clicking an exemplar (a specific trace ID associated with a metric data point) in a Prometheus metric graph. This allows you to jump directly to the corresponding trace in Tempo.
      • Example Query: histogram_quantile(.99,sum(rate(fastapi_requests_duration_seconds_bucket{app_name="app-a", path!="/metrics"}[1m])) by(path, le))
    • Traces to Logs: From a span in Tempo, use the Trace ID and tags (such as service.name) to query Loki and find the exact logs produced during that specific trace.
    • Logs to Traces: Use a regex-defined Trace ID within a Loki log entry to jump back to the distributed trace in Tempo.
    # Example query to find latency quantiles and identify exemplars
    histogram_quantile(.99,sum(rate(fastapi_requests_duration_seconds_bucket{app_name="app-a", path!="/metrics"}[1m])) by(path, le))
  3. Quick Start: Set up FastAPI Observability with Docker

    main

    To observe a FastAPI application using the three pillars of observability (Traces with Tempo, Metrics with Prometheus, and Logs with Loki), follow these steps:

    1. Install the Loki Docker Driver: This allows Docker to send logs directly to Loki.
      • For ARM64 architectures, use the arm64 image.
      • For AMD64 architectures, use the amd64 image.
    2. Start the stack: Use docker-compose up -d to launch all observability services.
      • Troubleshooting: If you see an error stating the loki plugin is disabled, run docker plugin enable loki.
    3. Generate traffic: Use tools like siege, curl, Locust, or k6 to send requests to the FastAPI application (typically at http://localhost:8000) so that data is collected.
    4. View results: Access the predefined FastAPI Observability dashboard at http://localhost:3000/ (default credentials: admin:admin).
    # 1. Install Loki Docker Driver (AMD64 example)
    docker plugin install grafana/loki-docker-driver:3.3.2-amd64 --alias loki --grant-all-permissions
    
    # 2. Start services
    docker-compose up -d
    
    # If plugin is disabled:
    docker plugin enable loki
    
    # 3. Send requests (using locust as an example)
    locust -f locustfile.py --headless --users 10 --spawn-rate 1 -H http://localhost:8000
  4. Generate Metrics with Exemplars for Trace Linking

    main

    To link metrics to traces in Grafana, use Prometheus exemplars. This involves retrieving the current trace_id from the OpenTelemetry span and adding it as an exemplar when observing a metric (e.g., a Histogram).

    Important: When exposing metrics, you must use generate_latest and CONTENT_TYPE_LATEST from prometheus_client.openmetrics.exposition instead of the standard prometheus_client module to ensure the exemplars are correctly included in the output.

    # Adding exemplar to a metric
    from opentelemetry import trace
    from prometheus_client import Histogram
    
    REQUESTS_PROCESSING_TIME = Histogram(
        "fastapi_requests_duration_seconds",
        "Histogram of requests processing time by path (in seconds)",
        ["method", "path", "app_name"],
    )
    
    span = trace.get_current_span()
    trace_id = trace.format_trace_id(span.get_span_context().trace_id)
    
    REQUESTS_PROCESSING_TIME.labels(
        method=method, path=path, app_name=self.app_name
    ).observe(after_time - before_time, exemplar={'TraceID': trace_id})
    
    # Exposing metrics correctly for OpenMetrics support
    from prometheus_client import REGISTRY
    from prometheus_client.openmetrics.exposition import CONTENT_TYPE_LATEST, generate_latest
    
    def metrics(request: Request) -> Response:
        return Response(generate_latest(REGISTRY), headers={"Content-Type": CONTENT_TYPE_LATEST})
  5. Generate Load for Observability Testing

    main

    Once the observability stack is running, you can use several tools to generate the traffic necessary to populate metrics, traces, and logs:

    • Shell Scripts: Use the provided request-script.sh or trace.sh.
    • Locust: A Python-based load testing tool.
      • Requirement: pip install locust.
      • Command: locust -f locustfile.py --headless --users 10 --spawn-rate 1 -H http://localhost:8000.
    • k6: A modern load testing tool.
      • Command: k6 run --vus 1 --duration 300s k6-script.js.
    # Using Locust
    locust -f locustfile.py --headless --users 10 --spawn-rate 1 -H http://localhost:8000
    
    # Using k6
    k6 run --vus 1 --duration 300s k6-script.js
  6. Run the FastAPI Observability Demo Application

    main

    This demo application showcases observability features including Tracing (via OpenTelemetry Python SDK), Metrics (via Prometheus Python Client), and Logging (via Python's logging module and OpenTelemetry logging integration).

    To run the application in a development environment using uv, use the following commands:

    uv sync
    uv run main.py
  7. Configure Loki Docker Driver for Multiline Logs

    main

    When using the Loki Docker Driver, use YAML anchors to define a standard logging configuration. Use loki-pipeline-stages to handle multiline logs from FastAPI by defining a multiline stage (matching the timestamp pattern) and a regex stage to parse the log components.

    x-logging: &default-logging
      driver: loki
      options:
        loki-url: 'http://localhost:3100/api/prom/push'
        loki-pipeline-stages: |
          - multiline:
              firstline: '^\d{4}-\d{2}-\d{2} \d{1,2}:\d{2}:\d{2}'
              max_wait_time: 3s
          - regex:
              expression: '^(?P<time>\d{4}-\d{2}-\d{2} \d{1,2}:\d{2}:\d{2},\d{3}) (?P<message>(?s:.*))$$\
    
    services:
       foo:
          image: foo
          logging: *default-logging
  8. Configure Prometheus Scrape Jobs

    main

    Define scrape jobs for your FastAPI applications in your prometheus.yml configuration file. Each application should have its own job name and target URL.

    scrape_configs:
      - job_name: 'app-a'
        scrape_interval: 5s
        static_configs:
          - targets: ['app-a:8000']
      - job_name: 'app-b'
        scrape_interval: 5s
        static_configs:
          - targets: ['app-b:8000']
  9. Customize Uvicorn access log format with Trace IDs

    main

    You can enrich Uvicorn access logs with observability metadata like trace_id, span_id, and resource.service.name. This is done by modifying the uvicorn.config.LOGGING_CONFIG before running the server.

    Use the following format string to include OpenTelemetry context: %(asctime)s %(levelname)s [%(name)s] [%(filename)s:%(lineno)d] [trace_id=%(otelTraceID)s span_id=%(otelSpanID)s resource.service.name=%(otelServiceName)s] - %(message)s

    if __name__ == "__main__":
        log_config = uvicorn.config.LOGGING_CONFIG
        log_config["formatters"]["access"]["fmt"] = "%(asctime)s %(levelname)s [%(name)s] [%(filename)s:%(lineno)d] [trace_id=%(otelTraceID)s span_id=%(otelSpanID)s resource.service.name=%(otelServiceName)s] - %(message)s"
        uvicorn.run(app, host="0.0.0.0", port=EXPOSE_PORT, log_config=log_config)
  10. Configure OpenTelemetry for FastAPI

    main

    To enable observability in a FastAPI application, use a setup function to configure the OpenTelemetry TracerProvider, attach an OTLPSpanExporter to send traces to a backend like Tempo, and instrument the FastAPI app. You can also enable log correlation to include trace_id and span_id in your logs using LoggingInstrumentor.

    # fastapi_app/utils.py
    
    def setting_otlp(app: ASGIApp, app_name: str, endpoint: str, log_correlation: bool = True) -> None:
        # set the service name to show in traces
        resource = Resource.create(attributes={
            "service.name": app_name
        })
    
        # set the tracer provider
        tracer = TracerProvider(resource=resource)
        trace.set_tracer_provider(tracer)
    
        tracer.add_span_processor(BatchSpanProcessor(
            OTLPSpanExporter(endpoint=endpoint)))
    
        if log_correlation:
            LoggingInstrumentor().instrument(set_logging_format=True)
    
        FastAPIInstrumentor.instrument_app(app, tracer_provider=tracer)
  11. Configure Loki logging for services

    main

    The observability stack uses a custom Docker logging driver configuration to send logs to Loki. This is defined via the x-logging anchor in the docker-compose.yaml file.

    To enable this logging for your services, apply the *default-logging anchor to the logging key. This configuration includes:

    • driver: loki
    • loki-url: http://localhost:3100/api/prom/push
    • loki-pipeline-stages: A pipeline that handles multiline logs (using a timestamp regex) and parses the log line into time and message fields using a regex expression.
    x-logging: &default-logging
      driver: loki
      options:
        loki-url: 'http://localhost:3100/api/prom/push'
        loki-pipeline-stages: |
          - multiline:
              firstline: '^\d{4}-\d{2}-\d{2} \d{1,2}:\d{2}:\d{2}'
              max_wait_time: 3s
          - regex:
              expression: '^(?P<time>\d{4}-\d{2}-\d{2} \d{1,2}:\d{2}:\d{2},\d{3}) (?P<message>(?s:.*))$$
    
    services:
      app-a:
        logging: *default-logging
  12. Configure Grafana Data Sources for Observability

    main

    To enable seamless navigation between metrics, traces, and logs, configure your Grafana data sources with specific linking settings:

    1. Prometheus (Exemplars): Set exemplarTraceIdDestinations to map the TraceID label to your Tempo datasource.
    2. Tempo (Trace to Logs): Configure tracesToLogsV2 with datasourceUid (e.g., loki) and tags to allow jumping from a trace to relevant logs.
    3. Loki (Derived Fields): Use derivedFields with a matcherRegex to extract the trace_id from log lines and create a link to Tempo.
    # Prometheus Exemplar Config
    jsonData:
      exemplarTraceIdDestinations:
         - datasourceUid: tempo
            name: TraceID
    
    # Tempo Trace-to-Logs Config
    jsonData:
      tracesToLogsV2:
        datasourceUid: loki
        tags:
          - key: service.name
            value: compose_service
    
    # Loki Derived Fields Config
    jsonData:
      derivedFields:
        - datasourceUid: tempo
          matcherRegex: (?:trace_id)=(\w+)
          matcherType: regex
          name: TraceID
          url: $${__value.raw}