Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[BUG]: FastAPI/Starlette integration: span resource names degrade to bare GET/POST under FastAPI >= 0.137 (when using lazy include_router) #18621

Description

@mrbcmorris

Tracer Version(s)

4.10.4

Python Version(s)

3.14

Pip Version(s)

N/A (uv 0.11.19)

Bug Report

Summary

After upgrading FastAPI from 0.136.x to 0.137.x, ddtrace's FastAPI/Starlette integration stops resolving route templates for any endpoint registered via include_router. The http.route tag and the span resource lose the include prefixes; endpoints registered at a router root (empty path) collapse to a bare GET / POST resource with no path at all.

This destroys per-endpoint resource aggregation in APM: every GET across the app lands in a single GET bucket (and likewise POST), so latency/error breakdowns by endpoint and any monitor/dashboard keyed on resource name break.

ddtrace and Starlette versions are held constant in the repro below — only the FastAPI version changes — so this is specifically a FastAPI 0.137 interaction.

Which ddtrace version

Reproduced on ddtrace==4.10.4 (latest stable). The Starlette integration on main (ahead of 4.11.0rc3) is unchanged in the relevant code, so newer releases are expected to behave the same.

Root cause

FastAPI 0.137.0 (PR fastapi/fastapi#15745, "Refactor internals to preserve APIRouter and APIRoute instances") changed APIRouter.include_router. It no longer clones each route onto a single flat parent router; instead it appends a lazy wrapper:

self.routes.append(_IncludedRouter(original_router=router, include_context=include_context))

Potential fix

Note: under 0.137 the leaf route no longer carries the full template — scope["route"].path_format returns only the relative leaf path ('', /{item_id}), so reading scope["route"] is not sufficient.

FastAPI 0.137 does expose the fully-composed template on the request scope. For a matched request, the full template is available at:

scope["fastapi"]["effective_route_context"].path_format

Verified against the repro above:

request /v1/items     -> effective path_format = '/v1/items'
request /v1/items/42  -> effective path_format = '/v1/items/{item_id}'

Workaround

Pin fastapi < 0.137 until the integration supports the new include model.

Reproduction Code

Reproduction

import os

os.environ["DD_TRACE_ENABLED"] = "true"
os.environ["DD_TRACE_AGENT_URL"] = "http://localhost:0"  # never actually sends
os.environ["DD_INSTRUMENTATION_TELEMETRY_ENABLED"] = "false"

from ddtrace import patch

patch(fastapi=True, starlette=True)

import fastapi
from fastapi import APIRouter, FastAPI

# app.include_router(v1, prefix="/v1") -> v1.include_router(items, prefix="/items")
items = APIRouter()


@items.get("")
def list_items():
  return []


@items.get("/{item_id}")
def get_item(item_id: str):
    return {"id": item_id}


v1 = APIRouter()
v1.include_router(items, prefix="/items")

app = FastAPI()
app.include_router(v1, prefix="/v1")

# Capture finished spans.
from ddtrace.trace import tracer

captured = []
_writer = tracer._span_aggregator.writer
_orig = _writer.write
_writer.write = lambda spans=None: (captured.extend(spans or []), _orig(spans))[1]

from starlette.testclient import TestClient

with TestClient(app) as client:
    client.get("/v1/items")
    client.get("/v1/items/42")

print(f"fastapi=={fastapi.__version__}")
for s in captured:
    if s.name == "fastapi.request":
        print(f"  resource={s.resource!r:34} http.route={s.get_tag('http.route')!r}")

Run against each FastAPI version (ddtrace + Starlette held constant):

uv run --no-project --with 'fastapi==0.136.3' --with 'ddtrace==4.10.4' --with 'httpx<0.28' python repro.py
uv run --no-project --with 'fastapi==0.137.1' --with 'ddtrace==4.10.4' --with 'httpx<0.28' python repro.py

Both pull starlette==1.3.1.

Expected (fastapi==0.136.3)

resource='GET /v1/items'              http.route='/v1/items'
resource='GET /v1/items/{item_id}'    http.route='/v1/items/{item_id}'

Actual (fastapi==0.137.1)

resource='GET '                       http.route=''
resource='GET /{item_id}'             http.route='/{item_id}'

The collection endpoint loses its path entirely; the item endpoint loses the /v1/items include prefixes.

Error Logs

No response

Libraries in Use

annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.13.0
bytecode==0.18.1
certifi==2026.5.20
ddtrace==4.10.4
envier==0.6.1
fastapi==0.137.1
h11==0.16.0
httpcore==1.0.9
httpx==0.27.2
idna==3.18
opentelemetry-api==1.42.1
pydantic==2.13.4
pydantic-core==2.46.4
sniffio==1.3.1
starlette==1.3.1
typing-extensions==4.15.0
typing-inspection==0.4.2
wrapt==2.2.1

Operating System

Linux asquin 7.0.11-arch1-1 #1 SMP PREEMPT_DYNAMIC Tue, 02 Jun 2026 18:26:58 +0000 x86_64 GNU/Linux

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions