Skip to content

.NET: Python: Add OpenTelemetry spans for agent, chat, and function middleware #8559

Description

Summary

Add first-class OpenTelemetry telemetry for middleware execution in the Python Agent Framework.

Current behavior

Agent, chat, and function middleware execute inside the existing middleware pipelines, but MAF does not emit dedicated middleware spans or middleware-specific span attributes. Their execution is therefore only visible indirectly within the enclosing agent, chat, or function/tool span.

Requested behavior

Emit a dedicated span for each middleware invocation, while preserving the existing parent span relationships:

  • Agent middleware spans under the invoke_agent span.
  • Chat middleware spans under the chat / inference span.
  • Function middleware spans under the function/tool execution span.

Suggested span attributes include:

  • Middleware category: agent, chat, or function.
  • Middleware implementation name.
  • Execution outcome: completed, terminated, or failed.
  • Duration.
  • Exception/error type when applicable.

Middleware telemetry should follow the existing observability configuration, exporter setup, sensitive-data controls, and OpenTelemetry semantic-convention guidance. Middleware arguments, messages, and results must not be captured unless sensitive telemetry is explicitly enabled and the data follows the existing redaction policy.

Acceptance criteria

  • Dedicated middleware spans are emitted for agent, chat, and function middleware.
  • Span names and attributes are documented and covered by unit tests.
  • Middleware spans preserve the correct parent-child relationships for streamed and non-streamed execution.
  • Normal middleware termination is distinguishable from middleware failure.
  • Middleware telemetry does not change execution behavior or break async stream cleanup.
  • Exceptions and cancellation are recorded according to existing MAF telemetry conventions.
  • The implementation works for class-based and callable middleware where the middleware name can be determined.

Related issues

Activity

  1. added
    .NETUsage: [Issues, PRs], Target: .Net
    pythonUsage: [Issues, PRs], Target: Python
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Sep 20, 2026
  2. changed the title [-]Python: Add OpenTelemetry spans for agent, chat, and function middleware[/-] [+].NET: Python: Add OpenTelemetry spans for agent, chat, and function middleware[/+] on Sep 20, 2026
  3. droideronline commented on Sep 22, 2026

    @droideronline
    ContributorAuthor

    Tao Chen (@TaoChenOSU) - kindly take a look when you get a chance

  4. rksharma-owg commented on Sep 27, 2026

    @rksharma-owg
    Contributor

    I traced the Python middleware and telemetry layers on main. AgentMiddlewareLayer runs before AgentTelemetryLayer; chat middleware likewise sits outside ChatTelemetryLayer; and function middleware wraps the tool invocation where the function span starts. A span around each middleware callback in the current pipelines would therefore be a parent of the existing agent/chat/tool span, rather than its child as requested here. Middleware that ends the call before call_next() never enters the inner telemetry layer.

    For short-circuited calls, should the agent/chat/tool parent span still be created outside the middleware pipeline, or should the middleware span attach to the ambient parent without an operation span? That choice seems necessary before changing the layering while preserving streaming cleanup and existing trace semantics.

  5. eavanvalkenburg commented on Sep 29, 2026

    @eavanvalkenburg
    Member

    Dineshsuriya D (@droideronline) thanks for this, and RKS (@rksharma-owg) is correct, that we indeed run middleware outside the current spans, because measuring that as part of the inner would conflate things, having said that, I do agree that it might make sense for telemetry spans around the middleware parts, so couple of questions:

    • Should it create and close spans, even if there is no middleware of that kind?
    • Should it be a span per middleware, or for the whole middleware pipeline?
  6. added
    .NETUsage: [Issues, PRs], Target: .Net
    vnextUsage: [Issues, PRs], Target: breaking-change features deferred to the next major release
    and removed
    .NETUsage: [Issues, PRs], Target: .Net
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Sep 29, 2026
  7. added
    agentsUsage: [Issues, PRs], Target: Single agent
    middlewareUsage: [Issues, PRs], Target: middleware related features
    observabilityUsage: [Issues, PRs], Target: observability related features
    on Sep 29, 2026
  8. sivakumar-mahalingam commented on Sep 29, 2026

    @sivakumar-mahalingam

    Starting the invoke_agent / chat / execute_tool span before the middleware pipeline (instead of inside the telemetry layer) would make each middleware span a natural child of the operation span, which matches the requested hierarchy. It also covers the short-circuit case cleanly - the operation span still exists and can carry an outcome like terminated when a middleware returns without calling call_next(), so traces don't lose the call entirely.

  9. droideronline commented on Sep 29, 2026

    @droideronline
    ContributorAuthor

    Eduard van Valkenburg (@eavanvalkenburg) asked:

    • Should it create and close spans, even if there is no middleware of that kind?
    • Should it be a span per middleware, or for the whole middleware pipeline?

    I’d suggest emitting no span when there’s no middleware to run, and one span per middleware invocation rather than one span for the whole pipeline. That gives us useful per-middleware names and outcomes without adding empty or redundant spans.

    For the hierarchy, I’d prefer each middleware span to be a child within its corresponding operation span (agent, model/chat, or tool call), rather than an ancestor of that span. That keeps middleware execution visible as part of the operation it affects. We should arrange the telemetry boundary/layering to make that nesting work, including for middleware that short-circuits before call_next(), and preserve the existing streaming cleanup behavior.

  10. eavanvalkenburg commented on Oct 27, 2026

    @eavanvalkenburg
    Member

    Dineshsuriya D (@droideronline) RKS (@rksharma-owg) Sivakumar Mahalingam (@sivakumar-mahalingam) thanks for the discussion so far. Before we decide on implementation, could you work with the issue owner on a detailed telemetry plan?

    Please build on the answers already given: one span per middleware invocation, and no spans for empty pipelines. The plan should spell out:

    • The exact span names and attributes for agent, chat, and function middleware, including how callable/class middleware names are represented and how the proposal fits existing telemetry conventions.
    • Precisely when spans are and are not created, and when they end: normal execution, short-circuit/termination, exceptions, cancellation, and streamed versus non-streamed execution. Include stream cleanup and final-response-only consumption.
    • The intended parent-child hierarchy, with example traces for ordinary and short-circuited calls. State explicitly whether existing agent/chat/tool span timing boundaries would change; they currently exclude middleware time intentionally.
    • Whether this should be configurable, why existing observability configuration is or is not sufficient, and the proposed defaults and scope of any new setting. Please justify the configuration needed rather than assuming every aspect needs a separate switch.
    • The focused regression coverage and sensitive-data behavior needed to preserve execution semantics, cleanup, and existing privacy controls.

    The body currently proposes Python behavior; call out any .NET parity questions for separate maintainer coordination. This is a request for a design proposal, not implementation approval, and does not change the existing vNext scheduling.

  11. rksharma-owg commented on Oct 28, 2026

    @rksharma-owg
    Contributor

    The requested per-middleware spans and empty-pipeline behavior are clear. One hierarchy decision remains: the existing inner operation spans intentionally exclude middleware time. Making middleware temporal children of those same spans would expand their boundaries. I suggest preserving those spans and adding an outer scope only when a category has middleware. This needs the issue owner's agreement because it changes the exact hierarchy requested in the issue body.

    Hierarchy and alternatives

    1. Recommended: preserve inner timing, add an envelope. Use proposed INTERNAL envelope names agent_framework.agent.middleware_scope, agent_framework.chat.middleware_scope, and agent_framework.function.middleware_scope. Each covers the middleware chain and any stream consumption/cleanup. Middleware spans nest as the chain executes; the existing invoke_agent, chat, and execute_tool spans remain inside the innermost callback and retain their present boundaries and duration metrics. Short-circuiting creates no synthetic inner operation span.
    2. Original requested hierarchy: start the existing operation span before middleware and end after middleware/stream cleanup. Middleware becomes a child, but operation timing expands and a span exists even when no inner operation runs. This requires explicit vNext approval and a decision about metric compatibility; it should not be slipped into a telemetry-only change.
    3. Smallest alternative: per-middleware spans directly under the ambient context with current inner spans beneath them. This needs no envelope or timing changes, but does not satisfy the desired operation-parent relationship. Do not fake containment by preallocating an inner operation context with misleading timestamps.

    Ordinary call with two agent middleware, under the recommended option:

    ambient caller
      agent_framework.agent.middleware_scope
        middleware agent module.First
          middleware agent module.Second
            invoke_agent <existing agent name>   [existing boundaries]
              ...existing chat/tool trace...
    

    Short circuit:

    ambient caller
      agent_framework.agent.middleware_scope
        middleware agent module.First
          middleware agent module.Cache          [outcome=terminated]
          # no later middleware or invoke_agent span
    

    Apply the same rule independently for chat and function categories, including nested calls. These envelope and attribute names are proposals, not OpenTelemetry standard names or existing APIs.

    Span names and attributes

    Proposed callback span name: middleware <category> <qualified-name>, category agent, chat, or function, kind INTERNAL. Proposed attributes: agent_framework.middleware.category, .name, .streaming (boolean), and .outcome. Outcome is one of completed, terminated, failed, cancelled, or closed (early explicit stream close without an exception). Optional error.type uses the exception class name, consistent with existing capture_exception behavior. Duration comes from span timestamps; no duplicate duration metric is needed initially.

    Resolve a function/bound method using its module and qualified name; for class-based middleware use the class module and qualified name; unwrap the framework's MiddlewareWrapper so every callable is not named after the wrapper. For partials use the underlying callable's name without argument values; for anonymous callables retain the stable qualified name or a bounded type-name fallback. Never use repr, object IDs, captured values, dynamic context data, or arguments. Repeated registrations may have the same name; distinct span IDs identify invocations. Repeated call_next() calls produce separate downstream invocations, rather than reusing a previously ended span.

    Lifecycle

    • Empty category, instrumentation disabled, or a middleware skipped by control flow: no callback span or envelope. Start only when execution actually reaches a callback, never merely at registration. Unsampled/no-op tracing must preserve execution.
    • Non-streamed execution: start immediately before process(context, call_next), keep it active for callback pre/post work and awaited downstream work, and end in finally. A return without call_next, or MiddlewareTermination, is terminated, not an error. Callback exceptions are failed; rethrow the identical exception without changing suppression rules. Cancellation is cancelled and must propagate unchanged, including BaseException paths.
    • Streamed execution: current agent/chat middleware pipelines are lazy through ResponseStream.from_awaitable; constructing an unconsumed stream must create no span. Once invoked, keep one callback span alive through that invocation's resulting stream, registered transforms/gates, final-result handling and cleanup, ending exactly once at terminal cleanup. Returning the stream wrapper is not completion.
    • Use non-current span handles and same-context activation around callback/pull/consumption work, following the existing telemetry approach. Do not leave context tokens attached across yields or reset them in another task. Nested pull contexts must preserve the correct middleware and inner-operation parents.
    • Exhaustion and get_final_response() finalize the same spans once. Explicit ResponseStream.close() before exhaustion ends them as closed; cancellation/error close preserves the corresponding outcome. Cleanup failures become failed only when they are the terminal error; they must not replace an already propagating primary error. An entirely unconsumed stream has no started spans. A started stream abandoned without closing follows the existing ownership contract; do not add telemetry-only draining, background tasks, finalizer-dependent correctness, or fabricated completion.
    • Function middleware is awaited rather than returning a streamed response, so it ends with that awaited callback, including repeated downstream/tool invocations. Agent/chat stream hooks must retain their existing execution order, buffering/gating semantics, and cleanup behavior.

    Configuration and privacy

    I propose using existing OBSERVABILITY_SETTINGS.ENABLED / disable_instrumentation() controls and the configured tracer provider/sampler, with no new per-category switches. Within the agreed vNext feature, emit only for nonempty executed pipelines when instrumentation is enabled. The feature changes trace shape, so document that change and the extra envelope cost; if maintainers require a staged opt-in, introduce one feature-wide control with default off, rather than several independent settings. Its public spelling/scope should be agreed before implementation.

    Use the existing sensitive-data gate for exception messages/stacks and status descriptions. With sensitive data disabled, record only bounded category/name/outcome and error class, no prompt, messages, function arguments/results, credentials, session state, instance attributes or exception text. No sensitive context serialization is needed even when sensitive telemetry is enabled. Reuse current instrumentation scope and semantic-convention selection for existing spans; do not label middleware-specific attributes as standard gen_ai conventions.

    Focused regression plan

    Use the repository's in-memory span exporter/fake clients and existing middleware/observability fixtures. Cover all three categories: empty/single/multiple pipelines; callable/class/partial naming; constructor and per-call registration; repeated and concurrent call_next; normal return, short circuit, MiddlewareTermination, exception and cancellation. Assert exact counts, parent IDs, existing inner timing boundaries and metric behavior with controlled barriers, plus unchanged return values, exception identity and downstream invocation counts.

    For agent/chat streams cover construction without consumption, normal iteration, final-response-only consumption, early close, error/cancellation before and after first update, transforms/gates/cleanup failures, repeated close/final-result calls, and creation/consumption in different tasks. Assert no leaked ambient span/context, exactly-once span finalization, and unchanged update order, buffering and cleanup. Add instrumentation-disabled, unsampled and sensitive-data-off canary checks including exception text and callable captures. Run focused middleware/observability suites plus required formatting/type checks, core suite and cloud validation before any eventual implementation PR. No tests were executed for this design-only response.

    .NET parity should be coordinated separately: map its delegating middleware and ActivitySource names, cancellation and async-enumerable disposal boundaries, existing operation metrics, and any future option. Python names or implementation details should not be imposed on .NET without its maintainers' agreement.

    The remaining design decision is whether to accept the envelope hierarchy while preserving inner timing, or explicitly change operation boundaries to match the original requested hierarchy. Implementation should wait for that direction and the existing vNext scheduling gate.

    AI assistance was used for the proposed design and source analysis. No implementation was prepared and no tests were executed for this design proposal.

    Source references

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

.NETUsage: [Issues, PRs], Target: .NetagentsUsage: [Issues, PRs], Target: Single agentmiddlewareUsage: [Issues, PRs], Target: middleware related featuresobservabilityUsage: [Issues, PRs], Target: observability related featurespythonUsage: [Issues, PRs], Target: PythonvnextUsage: [Issues, PRs], Target: breaking-change features deferred to the next major release

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions