Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to the Agent365 TypeScript SDK will be documented in this fi
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- **OutputScope**: Tracing scope for outgoing agent messages with caller details, conversation ID, source metadata, and parent span linking.
- **BaggageMiddleware**: Middleware for automatic OpenTelemetry baggage propagation from TurnContext.
- **OutputLoggingMiddleware**: Middleware that creates OutputScope spans for outgoing messages with lazy parent span linking via `A365_PARENT_SPAN_KEY`.
- **ObservabilityHostingManager**: Manager for configuring hosting-layer observability middleware with `ObservabilityHostingOptions`.

### Changed
- `InferenceScope.recordInputMessages()` / `recordOutputMessages()` now use JSON array format instead of comma-separated strings.
- `InvokeAgentScope.recordInputMessages()` / `recordOutputMessages()` now use JSON array format instead of comma-separated strings.

## [1.1.0] - 2025-12-09

### Changed
Expand Down
4 changes: 4 additions & 0 deletions packages/agents-a365-observability-hosting/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ export * from './utils/BaggageBuilderUtils';
export * from './utils/ScopeUtils';
export * from './utils/TurnContextUtils';
export { AgenticTokenCache, AgenticTokenCacheInstance } from './caching/AgenticTokenCache';
export { BaggageMiddleware } from './middleware/BaggageMiddleware';
export { OutputLoggingMiddleware, A365_PARENT_SPAN_KEY } from './middleware/OutputLoggingMiddleware';
export { ObservabilityHostingManager } from './middleware/ObservabilityHostingManager';
export type { ObservabilityHostingOptions } from './middleware/ObservabilityHostingManager';
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { TurnContext, Middleware } from '@microsoft/agents-hosting';
import { ActivityTypes, ActivityEventNames } from '@microsoft/agents-activity';
import { BaggageBuilder } from '@microsoft/agents-a365-observability';
import {
getExecutionTypePair,
getCallerBaggagePairs,
getTargetAgentBaggagePairs,
getTenantIdPair,
getSourceMetadataBaggagePairs,
getConversationIdAndItemLinkPairs,
} from '../utils/TurnContextUtils';

/**
* Middleware that propagates OpenTelemetry baggage context derived from TurnContext.
* Async replies (ContinueConversation) are passed through without baggage setup.
*/
export class BaggageMiddleware implements Middleware {

async onTurn(context: TurnContext, next: () => Promise<void>): Promise<void> {
const isAsyncReply =
context.activity?.type === ActivityTypes.Event &&
context.activity?.name === ActivityEventNames.ContinueConversation;

if (isAsyncReply) {
await next();
return;
}

const baggageScope = new BaggageBuilder()
.setPairs(getCallerBaggagePairs(context))
.setPairs(getTargetAgentBaggagePairs(context))
.setPairs(getTenantIdPair(context))
.setPairs(getSourceMetadataBaggagePairs(context))
.setPairs(getConversationIdAndItemLinkPairs(context))
.setPairs(getExecutionTypePair(context))
.build();

await baggageScope.run(async () => {
await next();
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { Middleware } from '@microsoft/agents-hosting';
import { logger } from '@microsoft/agents-a365-observability';
import { BaggageMiddleware } from './BaggageMiddleware';
import { OutputLoggingMiddleware } from './OutputLoggingMiddleware';

/**
* Configuration options for the hosting observability layer.
*/
export interface ObservabilityHostingOptions {
/** Enable baggage propagation middleware. Defaults to true. */
enableBaggage?: boolean;

/** Enable output logging middleware for tracing outgoing messages. Defaults to false. */
enableOutputLogging?: boolean;
}

/**
* Manager for configuring hosting-layer observability middleware.
*
* @example
* ```typescript
* const manager = new ObservabilityHostingManager();
* manager.configure(adapter, { enableOutputLogging: true });
* ```
*/
export class ObservabilityHostingManager {
private _configured = false;

/**
* Registers observability middleware on the adapter.
* Subsequent calls are ignored.
*/
configure(
adapter: { use(...middlewares: Array<Middleware>): void },
options: ObservabilityHostingOptions
): void {
if (this._configured) {
logger.warn('[ObservabilityHostingManager] Already configured. Subsequent configure() calls are ignored.');
return;
}

const enableBaggage = options.enableBaggage !== false;
const enableOutputLogging = options.enableOutputLogging === true;

if (enableBaggage) {
adapter.use(new BaggageMiddleware());
logger.info('[ObservabilityHostingManager] BaggageMiddleware registered.');
}
if (enableOutputLogging) {
adapter.use(new OutputLoggingMiddleware());
logger.info('[ObservabilityHostingManager] OutputLoggingMiddleware registered.');
}

logger.info(`[ObservabilityHostingManager] Configured. Baggage: ${enableBaggage}, OutputLogging: ${enableOutputLogging}.`);
this._configured = true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { TurnContext, Middleware, SendActivitiesHandler } from '@microsoft/agents-hosting';
import {
OutputScope,
AgentDetails,
TenantDetails,
CallerDetails,
ParentSpanRef,
logger,
} from '@microsoft/agents-a365-observability';
import { ScopeUtils } from '../utils/ScopeUtils';

/**
* TurnState key for the parent span reference.
* Set this in `turnState` to link OutputScope spans as children of an InvokeAgentScope.
*/
export const A365_PARENT_SPAN_KEY = 'A365ParentSpanId';
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A365_PARENT_SPAN_KEY stores a full ParentSpanRef (traceId/spanId/traceFlags), but the underlying string value 'A365ParentSpanId' implies it contains only a spanId. This is likely to confuse consumers and can lead to incorrect usage. Consider renaming the key string to reflect that it stores a parent span reference/context (and keep the exported constant name/value aligned).

Copilot uses AI. Check for mistakes.

/**
* Middleware that creates {@link OutputScope} spans for outgoing messages.
* Links to a parent span when {@link A365_PARENT_SPAN_KEY} is set in turnState.
*
* **Privacy note:** Outgoing message content is captured verbatim
* as span attributes and exported to the configured telemetry backend.
*/
export class OutputLoggingMiddleware implements Middleware {

async onTurn(context: TurnContext, next: () => Promise<void>): Promise<void> {
const agentDetails = ScopeUtils.deriveAgentDetails(context);
const tenantDetails = ScopeUtils.deriveTenantDetails(context);

if (!agentDetails || !tenantDetails) {
await next();
return;
}

const callerDetails = ScopeUtils.deriveCallerDetails(context);
const conversationId = ScopeUtils.deriveConversationId(context);
const sourceMetadata = ScopeUtils.deriveSourceMetadataObject(context);

context.onSendActivities(
this._createSendHandler(context, agentDetails, tenantDetails, callerDetails, conversationId, sourceMetadata)
);

await next();
}

/**
* Creates a send handler that wraps outgoing messages in OutputScope spans.
* Reads parent span ref lazily so the agent handler can set it during `next()`.
*/
private _createSendHandler(
turnContext: TurnContext,
agentDetails: AgentDetails,
tenantDetails: TenantDetails,
callerDetails?: CallerDetails,
conversationId?: string,
sourceMetadata?: { name?: string; description?: string },
): SendActivitiesHandler {
return async (_ctx, activities, sendNext) => {
const messages = activities
.filter((a) => a.type === 'message' && a.text)
.map((a) => a.text!);

if (messages.length === 0) {
return await sendNext();
}

const parentSpanRef: ParentSpanRef | undefined = turnContext.turnState.get(A365_PARENT_SPAN_KEY);
if (!parentSpanRef) {
logger.warn(
`[OutputLoggingMiddleware] No parent span ref in turnState under '${A365_PARENT_SPAN_KEY}'. OutputScope will not be linked to a parent.`
);
}

const outputScope = OutputScope.start(
{ messages },
agentDetails,
tenantDetails,
callerDetails,
conversationId,
sourceMetadata,
undefined,
parentSpanRef,
);
try {
return await sendNext();
} catch (error) {
outputScope.recordError(
error instanceof Error ? error : new Error(typeof error === 'string' ? error : JSON.stringify(error))
);
throw error;
} finally {
outputScope.dispose();
}
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,7 @@ import {
InferenceDetails,
InvokeAgentDetails,
ToolCallDetails,
ExecutionType
} from '@microsoft/agents-a365-observability';
import {
getExecutionTypePair,
} from './TurnContextUtils';

/**
* Unified utilities to populate scope tags from a TurnContext.
Expand Down Expand Up @@ -198,7 +194,6 @@ export class ScopeUtils {
public static buildInvokeAgentDetails(details: InvokeAgentDetails, turnContext: TurnContext): InvokeAgentDetails {
const agent = ScopeUtils.deriveAgentDetails(turnContext);
const srcMetaFromContext = ScopeUtils.deriveSourceMetadataObject(turnContext);
const executionTypePair = getExecutionTypePair(turnContext);
const baseRequest = details.request ?? {};
const baseSource = baseRequest.sourceMetadata ?? {};
const mergedSourceMetadata = {
Expand All @@ -212,7 +207,6 @@ export class ScopeUtils {
conversationId: ScopeUtils.deriveConversationId(turnContext),
request: {
...baseRequest,
executionType: executionTypePair.length > 0 ? (executionTypePair[0][1] as ExecutionType) : baseRequest.executionType,
sourceMetadata: mergedSourceMetadata
}
};
Expand Down Expand Up @@ -247,4 +241,5 @@ export class ScopeUtils {
const scope = ExecuteToolScope.start(details, agent, tenant, conversationId, sourceMetadata, undefined, startTime, endTime);
return scope;
}

}
2 changes: 1 addition & 1 deletion packages/agents-a365-observability/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export {
InferenceDetails,
InferenceOperationType,
InferenceResponse,
OutputResponse
OutputResponse,
} from './tracing/contracts';

// Scopes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export enum ExecutionType {
Unknown = 'Unknown'
}


/**
* Represents different roles that can invoke an agent
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,15 @@ export class InferenceScope extends OpenTelemetryScope {
* @param messages Array of input messages
*/
public recordInputMessages(messages: string[]): void {
this.setTagMaybe(OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, messages.join(','));
this.setTagMaybe(OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, JSON.stringify(messages));
}

/**
* Records the output messages for telemetry tracking.
* @param messages Array of output messages
*/
public recordOutputMessages(messages: string[]): void {
this.setTagMaybe(OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, messages.join(','));
this.setTagMaybe(OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, JSON.stringify(messages));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,14 +125,14 @@ export class InvokeAgentScope extends OpenTelemetryScope {
* @param messages Array of input messages
*/
public recordInputMessages(messages: string[]): void {
this.setTagMaybe(OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, messages.join(','));
this.setTagMaybe(OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, JSON.stringify(messages));
}

/**
* Records the output messages for telemetry tracking.
* @param messages Array of output messages
*/
public recordOutputMessages(messages: string[]): void {
this.setTagMaybe(OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, messages.join(','));
this.setTagMaybe(OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, JSON.stringify(messages));
}
}
Loading