Skip to content

App Insights / Langfuse cross-reference

When a pod uses both Azure Application Insights (via Azure Monitor OpenTelemetry) and Langfuse, two independent observability backends receive data for every request. This document explains what cross-reference data is written to each backend, how it is written, the navigation flows that result, and what improvements can be standardised in the shared SDK versus what must remain pod-specific.

What is written where

Field Written to Written from KQL table (classic / workspace)
langfuse.trace_id App Insights custom dimension request_span.set_attribute(...) requests / AppRequests
langfuse.trace_url App Insights custom dimension request_span.set_attribute(...) requests / AppRequests
app_insights.request_span_id Langfuse observation metadata chain_span.update(metadata=...) —
app_insights.query_url Langfuse observation metadata chain_span.update(metadata=...) —

App Insights has two table schemas depending on whether the workspace is classic or workspace-based: the classic schema uses lowercase table names (requests, traces, dependencies, exceptions) and column names (id, operation_Id, operation_ParentId, customDimensions); the workspace-based schema uses prefixed table names (AppRequests, AppTraces, etc.) with PascalCase columns (Id, OperationId, ParentId, Properties). Both expose the same data; the KQL examples in this document show the classic schema. Substitute the workspace equivalents as needed.

The span_id stored as app_insights.request_span_id in Langfuse is the App Insights HTTP server span's OTel span_id formatted as a 16-hex-char string (format(ctx.span_id, "016x")). This is the same value that App Insights writes to requests.id in Log Analytics.

app_insights.query_url is a pre-built Azure Portal Log Analytics URL for the query requests | where id == '<span_id>'. It is present only when the pod sets APP_INSIGHTS_RESOURCE_ID and APP_INSIGHTS_TENANT_ID in its environment.

The App Insights OperationId (the OTel trace ID formatted as a 32-char hex string) and the Langfuse trace ID are the same value. get_trace_id() returns the OTel trace ID; App Insights writes it to operation_Id (classic) / OperationId (workspace). The langfuse.trace_id custom dimension is therefore always reconstructible from the request row — it equals operation_Id. The explicit stamping is retained so that langfuse.trace_url (the clickable link to Langfuse) is available without any manual URL construction.

Architecture: dual TracerProvider

The cross-reference exists because the pod uses two separate OTel TracerProvider instances — one per backend. They cannot share a single provider because the Azure Monitor exporter and the Langfuse exporter impose conflicting configurations.

graph TD
  REQ["HTTP Request"]
  AI_TP["App Insights TracerProvider\ndedicated non-global provider\nFastAPIInstrumentor"]
  LF_TP["Langfuse TracerProvider\nglobal OTel provider\nsetup_tracing"]
  AI["App Insights\nrequests.id = request span_id\nrequests.customDimensions:\n  langfuse.trace_id\n  langfuse.trace_url\ntraces.operation_ParentId = request span_id"]
  LF["Langfuse\npathfinder.ask observation\nmetadata.app_insights.request_span_id\nmetadata.app_insights.query_url"]
  REQ --> AI_TP
  REQ --> LF_TP
  AI_TP --> AI
  LF_TP --> LF

Because App Insights owns the OTel trace root (the HTTP server span), Langfuse's first observation (pathfinder.ask) is technically a child in the OTel context tree — but it is the root of everything Langfuse sees, since the App Insights server span never reaches the Langfuse exporter.

Engineer navigation flows

graph TD
  AI_REQ["App Insights\nrequests table row"]
  AI_TRACES["App Insights\ntraces table row"]
  LF["Langfuse trace\n(pathfinder.ask observation)"]

  AI_REQ -->|"customDimensions.langfuse.trace_url\none-click link to Langfuse"| LF
  LF -->|"metadata.app_insights.query_url\none-click link to Log Analytics"| AI_REQ
  LF -->|"metadata.app_insights.request_span_id\nKQL: requests where id == span_id"| AI_REQ
  AI_TRACES -->|"operation_ParentId = requests.id\nKQL: requests where id == operation_ParentId"| AI_REQ

Flow A — App Insights request → Langfuse trace

Starting point: you found a specific HTTP request in App Insights and want to inspect the Langfuse trace for the AI part of that call.

  1. In Log Analytics: requests | where id == "<request_span_id>" or browse to the request row directly.
  2. Open the row and expand customDimensions.
  3. customDimensions.langfuse.trace_url is a direct deep link to the Langfuse trace. langfuse.trace_id holds the bare trace ID if you want to construct the URL yourself.

Flow B — App Insights trace/log entry → Langfuse trace

Starting point: you found an application log entry (Python logging module output) in the traces table and need to find the parent request and then the Langfuse trace.

All telemetry for the same HTTP request shares the same operation_Id (classic schema) / OperationId (workspace schema). Use this to join:

// Classic schema
let op_id = traces | where ... | project operation_Id | take 1;
requests
| where operation_Id == toscalar(op_id)
| project timestamp, name, duration, resultCode,
    langfuse_trace_url = customDimensions["langfuse.trace_url"]

// Workspace-based schema
let op_id = AppTraces | where ... | project OperationId | take 1;
AppRequests
| where OperationId == toscalar(op_id)
| project TimeGenerated, Name, DurationMs, ResultCode,
    langfuse_trace_url = Properties["langfuse.trace_url"]

traces.operation_ParentId (classic) / AppTraces.ParentId (workspace) equals the request's id / Id only for log entries that are direct children of the request span. Using OperationId is more reliable since it captures all telemetry for the request, not just direct children.

Flow C — Langfuse trace → App Insights request

Starting point: you are inspecting a Langfuse trace and want to see the full App Insights request telemetry (HTTP headers, dependencies, status code, timing).

  1. Open the pathfinder.ask chain observation in the Langfuse trace detail view.
  2. In observation metadata, click app_insights.query_url to jump directly to the Log Analytics query for this request. The link opens the Azure Portal with requests | where id == '<span_id>' pre-populated and scoped to the pod's App Insights resource.
  3. Alternatively, copy app_insights.request_span_id and run requests | where id == "<span_id>" manually in Log Analytics.
  4. From the request row you can drill into dependencies, traces, exceptions for the same operation via operation_Id.

app_insights.query_url is present when the pod configures APP_INSIGHTS_RESOURCE_ID and APP_INSIGHTS_TENANT_ID. When those env vars are absent, only app_insights.request_span_id is available (step 3 above).

Quick combined KQL to see the full request context when navigating manually:

// Classic schema
let span_id = "a1b2c3d4e5f60718";  // from Langfuse metadata
requests
| where id == span_id
| project timestamp, name, duration, resultCode, operation_Id,
    langfuse_url = customDimensions["langfuse.trace_url"]
// All log traces for the same request (operation_Id from the request row above)
let op_id = "00112233445566778899aabbccddeeff";
traces
| where operation_Id == op_id
| order by timestamp asc
| project timestamp, message, severityLevel, operation_ParentId
// Workspace-based schema equivalents
AppRequests | where Id == "<span_id>"
AppTraces   | where OperationId == "<op_id>"

Implementation: SDK utilities

All cross-reference writes are provided by semantic_tracing as public utilities. Pods import them directly.

from semantic_tracing import (
    get_app_insights_query_url,
    link_app_insights_to_langfuse,
)

propagate() captures the currently active OTel span at entry and stores it in a ContextVar. Enter propagate() before span() so that the App Insights HTTP server span is still current — link_app_insights_to_langfuse() can then be called with no arguments:

from semantic_tracing import link_app_insights_to_langfuse, propagate, span

# propagate() wraps span() — App Insights server span is captured automatically.
with propagate(trace_name="myapp.ask", user_id=..., session_id=...):
    with span("myapp.ask", as_type="chain") as s:
        link_app_insights_to_langfuse()  # no args; both spans read from context
        with span("myapp.answer", as_type="generation"):
            # ... rest of handler

link_app_insights_to_langfuse resolves the Langfuse trace-URL prefix automatically on its first call and caches the result for the process lifetime. No startup hook or app.state plumbing is needed.

TraceContext ContextVar

propagate() also populates TRACE_CONTEXT (a ContextVar[TraceContext | None]) with the cross-backend IDs for the current request:

from semantic_tracing import get_trace_context

# Anywhere inside a `with propagate():` block:
tc = get_trace_context()
if tc:
    print(tc.langfuse_trace_id)               # 32-hex OTel trace ID
    print(tc.app_insights_operation_id)        # same value, App Insights name
    print(tc.app_insights_request_span_id)     # 16-hex request span_id

get_trace_context() returns None outside a propagate() scope or when the App Insights span was not valid at entry time. All three IDs are the same 32/16-hex strings used by App Insights and Langfuse internally.

Streaming routes and explicit req_span

In streaming async generators the OTel context from the outer handler does not carry through. Capture req_span in the handler body and pass it explicitly:

from opentelemetry import trace as otel_trace

req_span = otel_trace.get_current_span()  # captured before _gen() is iterated

async def _gen():
    with span("myapp.ask", as_type="chain") as s:
        with propagate(trace_name="myapp.ask", ...):
            link_app_insights_to_langfuse(req_span)  # chain_span still implicit
            with span("myapp.answer", as_type="generation"):
                ...

Passing req_span explicitly bypasses the ContextVar; chain_span still defaults to the active Langfuse observation (update_current_span).

Set two env vars in the pod's .env to enable app_insights.query_url in Langfuse observation metadata:

Env var Value
APP_INSIGHTS_RESOURCE_ID App Insights component ARM resource ID: /subscriptions/{sub}/resourceGroups/{rg}/providers/microsoft.insights/components/{name}
APP_INSIGHTS_TENANT_ID Azure tenant GUID (e.g. c8823c91-...) or domain

Both must be set. When either is absent, link_app_insights_to_langfuse omits app_insights.query_url silently — no error and no change to the other cross-reference fields.

Pods that need to generate Log Analytics URLs for custom queries can call get_app_insights_query_url directly:

url = get_app_insights_query_url(
    'requests | where id == "a1b2c3d4e5f60718"'
)
# → https://portal.azure.com#@{tenant}/blade/...

The resource_id and tenant params override the env vars when provided.

Note on workspace-based schema The same URL format works for Log Analytics workspace resource IDs (microsoft.operationalinsights/workspaces/...) — set APP_INSIGHTS_RESOURCE_ID to the workspace resource ID instead, and use workspace-based table names (AppRequests | where Id == ...) in your KQL. The default KQL generated by link_app_insights_to_langfuse uses the classic requests table, so it expects the App Insights component resource ID.

Known limitation: trace-level metadata in Langfuse

app_insights.request_span_id and app_insights.query_url are written to the observation metadata of the Langfuse chain span (e.g. pathfinder.ask). In the Langfuse UI this means you must click into that observation node to see them — the trace header itself does not show them.

The App Insights HTTP server span is the OTel trace root but is never exported to Langfuse. The Langfuse 5.x Python SDK does not expose a method to update trace-level metadata from within a running trace (update_current_trace does not exist on the Langfuse facade). Trace-level metadata can only be set via propagate_attributes(metadata=...) at scope-entry time — before any observations are created — which means the App Insights identifiers (only available after the route handler starts) cannot be placed in the Langfuse trace header. Revisit if Langfuse adds this API.

SDK vs pod responsibilities

Capability Where it belongs Status
link_app_insights_to_langfuse() SDK (semantic_tracing) Implemented — all args optional (v0.3.6); prefix auto-resolved (v0.3.5)
TraceContext / TRACE_CONTEXT / get_trace_context() SDK (semantic_tracing) Implemented (v0.3.6) — populated by propagate()
get_app_insights_query_url() SDK (semantic_tracing) Implemented
resolve_langfuse_trace_url_prefix() SDK (semantic_tracing) Deprecated (v0.3.5) — no longer required
Call propagate() before span() so App Insights span is captured Pod convention Implemented (Project Green v0.3.6 migration)
propagate(log_trace_url=True) — CLI/log URL output SDK (propagate()) — opt-in param Implemented
app_insights.request_span_id → Langfuse trace-level metadata Blocked — Langfuse 5.x has no update_current_trace Blocked; revisit if Langfuse adds this API