Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9fff531
Add session id to scope spans
nikhilc-microsoft Sep 14, 2026
70cfe81
chore: record Copilot session
nikhilc-microsoft Sep 14, 2026
cd11261
Add custom baggage metadata tracking
nikhilc-microsoft Sep 14, 2026
73bbc5a
feat(a365): propagate registered custom baggage
nikhilc-microsoft Sep 14, 2026
7f7c0ae
docs(a365): document custom baggage propagation
nikhilc-microsoft Sep 14, 2026
e64f5d4
fix(a365): address final baggage review findings
nikhilc-microsoft Sep 14, 2026
cb0a79b
chore(a365): format baggage review test
nikhilc-microsoft Sep 14, 2026
faa6af7
fix(a365): finish baggage review follow-ups
nikhilc-microsoft Sep 14, 2026
e42df19
docs: link custom baggage changelog entry
nikhilc-microsoft Sep 14, 2026
599943d
Merge origin/main into feature/a365-custom-baggage
nikhilc-microsoft Sep 17, 2026
90f7230
docs(a365): list all enriched GenAI operations
nikhilc-microsoft Sep 17, 2026
1a44019
test(a365): remove focused baggage API typecheck
nikhilc-microsoft Sep 17, 2026
097142d
docs: design A365 GenAI scope classification
nikhilc-microsoft Sep 18, 2026
1c7e711
docs: plan A365 GenAI scope classification
nikhilc-microsoft Sep 18, 2026
fc7a8f7
fix(a365): classify supported GenAI instrumentation scopes
nikhilc-microsoft Sep 18, 2026
6ab2f6e
fix(a365): block ambient operation baggage
nikhilc-microsoft Sep 18, 2026
c48fd08
fix(a365): register configured OpenAI tracer scope
nikhilc-microsoft Sep 18, 2026
5e99165
test(a365): cover GenAI scope fallback lifecycle
nikhilc-microsoft Sep 18, 2026
641a003
test(a365): cover custom scope descendant regression
nikhilc-microsoft Sep 18, 2026
7fd3fa3
Fix A365 custom scope baggage contracts
nikhilc-microsoft Sep 18, 2026
9b454ef
chore: remove internal scope planning artifacts
nikhilc-microsoft Sep 18, 2026
ec37d5c
fix(a365): limit scope fallback to built-in instrumentations
nikhilc-microsoft Sep 18, 2026
aae5e60
fix(a365): scope invoke server baggage
nikhilc-microsoft Sep 22, 2026
6c13e62
fix(a365): align invoke server baggage builder
nikhilc-microsoft Sep 22, 2026
b3dfdae
fix(a365): validate invoke server baggage
nikhilc-microsoft Sep 22, 2026
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
101 changes: 66 additions & 35 deletions A365_DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,21 @@ const invokeScope = InvokeAgentScope.start(
requestParameters: {
model: "gpt-4o",
outputType: "json",
systemInstructions: [
{ type: "text", content: "You are a helpful assistant." },
],
systemInstructions: [{ type: "text", content: "You are a helpful assistant." }],
},
},
{ agentId: "agent-1", tenantId: "tenant-1", providerName: "openai" },
);

invokeScope.run(async () => {
const toolScope = ExecuteToolScope.start(
{ conversationId: "conv-123" },
{ conversationId: "conv-123", sessionId: "session-456" },
{ toolName: "Search", input: { query: "hello" } },
{ agentId: "agent-1", tenantId: "tenant-1" },
);

const inferenceScope = InferenceScope.start(
{ conversationId: "conv-123" },
{ conversationId: "conv-123", sessionId: "session-456" },
{ operationName: InferenceOperationType.ChatCompletion },
{ agentId: "agent-1", tenantId: "tenant-1" },
);
Expand All @@ -64,29 +62,34 @@ invokeScope.recordResponseParameters({
invokeScope.dispose();
```

`InvokeAgentScope`, `InferenceScope`, and `ExecuteToolScope` accept `request.sessionId`.
When you provide it, those scopes write `microsoft.session.id` directly on the created
span instead of relying on later baggage enrichment. `OutputScope` does not currently
propagate `request.sessionId` directly.

`InvokeAgentScope.start()` captures request parameters immediately, while `recordResponseParameters()`
captures response and usage values after the agent completes.

| Input field | Emitted attribute key |
| --- | --- |
| `requestParameters.model` | `gen_ai.request.model` |
| `requestParameters.seed` | `gen_ai.request.seed` |
| `requestParameters.choiceCount` | `gen_ai.request.choice.count` |
| `requestParameters.frequencyPenalty` | `gen_ai.request.frequency_penalty` |
| `requestParameters.maxTokens` | `gen_ai.request.max_tokens` |
| `requestParameters.presencePenalty` | `gen_ai.request.presence_penalty` |
| `requestParameters.stopSequences` | `gen_ai.request.stop_sequences` |
| `requestParameters.temperature` | `gen_ai.request.temperature` |
| `requestParameters.topP` | `gen_ai.request.top_p` |
| `requestParameters.dataSourceId` | `gen_ai.data_source.id` |
| `requestParameters.outputType` | `gen_ai.output.type` |
| `requestParameters.systemInstructions` | `gen_ai.system_instructions` (JSON-serialized parts array) |
| `responseParameters.finishReasons` | `gen_ai.response.finish_reasons` |
| `responseParameters.inputTokens` | `gen_ai.usage.input_tokens` |
| `responseParameters.outputTokens` | `gen_ai.usage.output_tokens` |
| `responseParameters.cacheWriteInputTokens` | `gen_ai.usage.cache_write.input_tokens` |
| `responseParameters.cacheReadInputTokens` | `gen_ai.usage.cache_read.input_tokens` |
| `agentDetails.providerName` | `gen_ai.provider.name` |
| Input field | Emitted attribute key |
| ------------------------------------------ | ---------------------------------------------------------- |
| `requestParameters.model` | `gen_ai.request.model` |
| `requestParameters.seed` | `gen_ai.request.seed` |
| `requestParameters.choiceCount` | `gen_ai.request.choice.count` |
| `requestParameters.frequencyPenalty` | `gen_ai.request.frequency_penalty` |
| `requestParameters.maxTokens` | `gen_ai.request.max_tokens` |
| `requestParameters.presencePenalty` | `gen_ai.request.presence_penalty` |
| `requestParameters.stopSequences` | `gen_ai.request.stop_sequences` |
| `requestParameters.temperature` | `gen_ai.request.temperature` |
| `requestParameters.topP` | `gen_ai.request.top_p` |
| `requestParameters.dataSourceId` | `gen_ai.data_source.id` |
| `requestParameters.outputType` | `gen_ai.output.type` |
| `requestParameters.systemInstructions` | `gen_ai.system_instructions` (JSON-serialized parts array) |
| `responseParameters.finishReasons` | `gen_ai.response.finish_reasons` |
| `responseParameters.inputTokens` | `gen_ai.usage.input_tokens` |
| `responseParameters.outputTokens` | `gen_ai.usage.output_tokens` |
| `responseParameters.cacheWriteInputTokens` | `gen_ai.usage.cache_write.input_tokens` |
| `responseParameters.cacheReadInputTokens` | `gen_ai.usage.cache_read.input_tokens` |
| `agentDetails.providerName` | `gen_ai.provider.name` |

System instructions may contain sensitive content. Only capture them when you
intend to store prompt text and have reviewed downstream access controls.
Expand All @@ -103,6 +106,11 @@ const baggageScope = new BaggageBuilder()
.agentId("agent-1")
.conversationId("conv-123")
.sessionId("session-456")
.customAttribute("deployment.ring", "firstrelease")
.customAttributes({
"feature.name": "grounded-chat",
"customer.segment": "internal",
})
.build();

baggageScope.run(() => {
Expand All @@ -111,6 +119,29 @@ baggageScope.run(() => {
});
```

- Baggage may cross process and service boundaries when you inject/extract context. Treat it like
inbound and outbound metadata: `_internal.custom_keys` registration metadata can arrive through
inbound baggage headers, applications must reject or sanitize untrusted baggage headers at the
edge, and you must never put secrets, access tokens, or PII in baggage keys or values.
- `customAttribute()` and `customAttributes()` trim keys and values before storing them. Blank
keys/values are dropped, keys containing commas are rejected, and the reserved
`_internal.custom_keys` metadata key cannot be set directly.
- Custom baggage enrichment is opt-in. Only keys registered through `customAttribute()` or
`customAttributes()` are copied from baggage onto spans; plain `setPairs()` entries stay in
baggage only.
- Automatic baggage-to-span enrichment only runs for recognized GenAI spans whose
`gen_ai.operation.name` is `invoke_agent`, `execute_tool`, `output_messages`,
`apply_guardrail`, `chat`, `Chat`, `TextCompletion`, or `GenerateContent`.
- For the built-in LangChain and OpenAI Agents instrumentations, enrichment also recognizes
their exact instrumentation scope names when the final GenAI operation is not available at
span start. Configured custom tracer names, scope prefixes, and unrelated child scopes are not
matched.
- Invoke-agent-only baggage keys stay invoke-agent-only even when registered through
`_internal.custom_keys`; unknown or non-`invoke_agent` GenAI spans never receive those caller
agent attributes.
- Explicit span attributes win over baggage. If a span already has a value for a registered custom
key, the span value is preserved.

## Hosting

Use `configureA365Hosting` to register the A365 middleware on an adapter.
Expand Down Expand Up @@ -179,17 +210,17 @@ network-only delivery. It applies only to the A365 HTTP exporter, so set

### Durable Delivery Defaults

| Option | Default | Notes |
| ------------------------------------ | ------------------------- | --------------------------------------------------------------------------------- |
| `enabled` | `true` | Durable delivery stays on unless you explicitly disable it |
| Option | Default | Notes |
| ------------------------------------ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | `true` | Durable delivery stays on unless you explicitly disable it |
| `storageDirectory` | auto | Uses the configured directory, or creates a secure platform-specific default root plus a stable per-application `app-<hash>` partition |
| `maxStorageBytes` | `50 * 1024 * 1024` | Bounds pending, quarantined, active leased, and non-stale temporary records within the current `app-<hash>` partition only |
| `maxRecordAgeMilliseconds` | `2 * 24 * 60 * 60 * 1000` | Expired records are pruned before capacity eviction, within the current `app-<hash>` partition only |
| `replayIntervalMilliseconds` | `2 * 60 * 1000` | Scheduled replay cadence |
| `maxReplayBatchSize` | `10` | Maximum records claimed per replay pass |
| `leaseDurationMilliseconds` | `2 * 60 * 1000` | Reclaims stale replay leases |
| `shutdownTimeoutMilliseconds` | `10_000` | Shared shutdown budget for accepted live exports and admitted durable handoff completion |
| `tokenResolutionTimeoutMilliseconds` | `30_000` | Timeout per replay token-resolution attempt |
| `maxStorageBytes` | `50 * 1024 * 1024` | Bounds pending, quarantined, active leased, and non-stale temporary records within the current `app-<hash>` partition only |
| `maxRecordAgeMilliseconds` | `2 * 24 * 60 * 60 * 1000` | Expired records are pruned before capacity eviction, within the current `app-<hash>` partition only |
| `replayIntervalMilliseconds` | `2 * 60 * 1000` | Scheduled replay cadence |
| `maxReplayBatchSize` | `10` | Maximum records claimed per replay pass |
| `leaseDurationMilliseconds` | `2 * 60 * 1000` | Reclaims stale replay leases |
| `shutdownTimeoutMilliseconds` | `10_000` | Shared shutdown budget for accepted live exports and admitted durable handoff completion |
| `tokenResolutionTimeoutMilliseconds` | `30_000` | Timeout per replay token-resolution attempt |

### Operational Notes

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## [Unreleased]

### Features Added
- Add manual `sessionId` propagation to `ExecuteToolScope` and `InferenceScope`, plus opt-in custom baggage enrichment for recognized GenAI spans through `BaggageBuilder.customAttribute()` and `customAttributes()`. [#242](https://github.com/microsoft/opentelemetry-distro-javascript/pull/242)
- Add GenAI v1.42 InvokeAgent request, response, cache-token, and provider attribute capture for manual A365 scopes. [#239](https://github.com/microsoft/opentelemetry-distro-javascript/pull/239)

### Other Changes
Expand Down
3 changes: 3 additions & 0 deletions src/a365/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

import { MICROSOFT_OPENTELEMETRY_VERSION } from "../types.js";

/** Internal baggage metadata key for registered custom attribute names. */
export const INTERNAL_CUSTOM_KEYS_METADATA_KEY = "_internal.custom_keys";

/**
* OpenTelemetry constants for A365 observability.
*
Expand Down
135 changes: 117 additions & 18 deletions src/a365/middleware/BaggageBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,64 @@

import { propagation, context as otelContext } from "@opentelemetry/api";
import type { Context } from "@opentelemetry/api";
import { OpenTelemetryConstants } from "../constants.js";
import { INTERNAL_CUSTOM_KEYS_METADATA_KEY, OpenTelemetryConstants } from "../constants.js";

function getPairEntries<T>(
pairs: Record<string, T> | Iterable<[string, T]>,
): Iterable<[string, T]> {
if (Symbol.iterator in Object(pairs)) {
return pairs as Iterable<[string, T]>;
}

return Object.entries(pairs);
}

function normalizeValue(value: string | null | undefined): string | undefined {
if (value === null || value === undefined) {
return undefined;
}

const trimmed = value.trim();
return trimmed || undefined;
}

function normalizeCustomKey(key: string): string | undefined {
const trimmed = key.trim();
if (!trimmed || trimmed.includes(",") || trimmed === INTERNAL_CUSTOM_KEYS_METADATA_KEY) {
return undefined;
}

return trimmed;
}

function parseCustomKeys(value: string | undefined): Set<string> {
const keys = new Set<string>();
if (!value) {
return keys;
}

for (const rawKey of value.split(",")) {
const key = normalizeCustomKey(rawKey);
if (key) {
keys.add(key);
}
}

return keys;
}

function serializeCustomKeys(customKeys: Iterable<string>): string | undefined {
const normalizedKeys = new Set<string>();
for (const customKey of customKeys) {
const normalizedKey = normalizeCustomKey(customKey);
if (normalizedKey) {
normalizedKeys.add(normalizedKey);
}
}

const sortedKeys = [...normalizedKeys].sort((left, right) => left.localeCompare(right));
return sortedKeys.length > 0 ? sortedKeys.join(",") : undefined;
}

/**
* Fluent builder for setting OpenTelemetry baggage values.
Expand All @@ -31,6 +88,7 @@ import { OpenTelemetryConstants } from "../constants.js";
*/
export class BaggageBuilder {
private pairs: Map<string, string> = new Map();
private customKeys: Set<string> = new Set();

/** Set the operation source baggage value (e.g., ATG, ACF). */
operationSource(value: string | null | undefined): BaggageBuilder {
Expand Down Expand Up @@ -177,26 +235,53 @@ export class BaggageBuilder {
* Set multiple baggage pairs from a dictionary or iterable.
* @param pairs Dictionary or iterable of key-value pairs
*/

setPairs(
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- preserve source compatibility for interface/class-typed callers
pairs: Record<string, any> | Iterable<[string, any]> | null | undefined,
): BaggageBuilder {
if (!pairs) {
return this;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let entries: Iterable<[string, any]>;
if (Symbol.iterator in Object(pairs)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
entries = pairs as Iterable<[string, any]>;
} else {
entries = Object.entries(pairs);
for (const [key, value] of getPairEntries(pairs)) {
if (value !== null && value !== undefined) {
this.set(key, String(value));
}
}

return this;
}

/**
* Set a single custom baggage pair and register its key for metadata propagation.
*/
customAttribute(key: string, value: string | null | undefined): BaggageBuilder {
const normalizedKey = normalizeCustomKey(key);
const normalizedValue = normalizeValue(value);

if (normalizedKey && normalizedValue) {
this.pairs.set(normalizedKey, normalizedValue);
this.customKeys.add(normalizedKey);
}

return this;
}

/**
* Set multiple custom baggage pairs and register their keys for metadata propagation.
* @param pairs Dictionary or iterable of key-value pairs
*/
customAttributes(
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- preserve source compatibility for interface/class-typed callers
pairs: Record<string, any> | Iterable<[string, any]> | null | undefined,
): BaggageBuilder {
if (!pairs) {
return this;
}

for (const [key, value] of entries) {
for (const [key, value] of getPairEntries(pairs)) {
if (value !== null && value !== undefined) {
this.set(key, String(value));
this.customAttribute(key, String(value));
}
}

Expand All @@ -208,18 +293,16 @@ export class BaggageBuilder {
* @returns A BaggageScope that can run callbacks under the baggage context
*/
build(): BaggageScope {
return new BaggageScope(this.pairs);
return new BaggageScope(this.pairs, this.customKeys);
}

/**
* Add a baggage key/value if the value is not null or whitespace.
*/
private set(key: string, value: string | null | undefined): void {
if (value !== null && value !== undefined) {
const trimmed = value.trim();
if (trimmed) {
this.pairs.set(key, trimmed);
}
const trimmed = normalizeValue(value);
if (trimmed) {
this.pairs.set(key, trimmed);
}
}

Expand All @@ -244,18 +327,34 @@ export class BaggageScope {
/** @internal Exposed for testing. */
readonly contextWithBaggage: Context;

constructor(pairs: Map<string, string>) {
constructor(pairs: Map<string, string>, customKeys: ReadonlySet<string> = new Set()) {
// 1. Start from current active context
const currentCtx = otelContext.active();

// 2. Build merged baggage
let bag = propagation.getBaggage(currentCtx) ?? propagation.createBaggage({});
const mergedCustomKeys = parseCustomKeys(
bag.getEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY)?.value,
);

for (const [key, value] of pairs.entries()) {
if (value && value.trim()) {
bag = bag.setEntry(key, { value });
}
}

for (const customKey of customKeys) {
const normalizedKey = normalizeCustomKey(customKey);
if (normalizedKey) {
mergedCustomKeys.add(normalizedKey);
}
}

const customKeysMetadata = serializeCustomKeys(mergedCustomKeys);
bag = customKeysMetadata
? bag.setEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY, { value: customKeysMetadata })
: bag.removeEntry(INTERNAL_CUSTOM_KEYS_METADATA_KEY);

// 3. Create a new context that carries that baggage
this.contextWithBaggage = propagation.setBaggage(currentCtx, bag);
}
Expand Down
Loading
Loading