Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2d03e55
feat(telemetry): Phase 3 — qwen-code.subagent span with concurrent is…
doudouOUC May 21, 2026
a79902f
fix(telemetry): address #4410 review — wenshao C1/C2 + Copilot path-typo
doudouOUC May 22, 2026
4aaad4d
fix(telemetry): address #4410 review-2 — wenshao 8/10 (3 deferred)
doudouOUC May 22, 2026
6380631
fix(telemetry): address #4410 review-3 — wenshao 6/7 (1 deferred)
doudouOUC May 22, 2026
87ab207
fix(telemetry): address #4410 review-4 — wenshao 8/8 (all landed)
doudouOUC May 23, 2026
cf37108
fix(telemetry): address #4410 review-5 — wenshao 4/5 (1 deferred)
doudouOUC May 23, 2026
3f2e1cf
fix(telemetry): address #4410 review-6 — wenshao 6/6 (all landed)
doudouOUC May 23, 2026
a301b1b
fix(telemetry): address #4410 review-7 — wenshao 2/2 (all landed)
doudouOUC May 23, 2026
0ea0063
fix(telemetry): address #4410 review-8 — wenshao 3/3 (all landed)
doudouOUC May 23, 2026
5b0404a
fix(telemetry): address #4410 review-9 — wenshao 2/2 (all landed)
doudouOUC May 23, 2026
8d2aba7
Merge origin/main into feat/telemetry-phase-3-subagent-spans
doudouOUC May 30, 2026
01786dc
fix(telemetry): log TTL-sweep race on child span double-end
doudouOUC May 31, 2026
5af4679
fix(telemetry): extend TTL-race debug logging to remaining end-span h…
doudouOUC May 31, 2026
8e1fc5f
fix(telemetry): correct subagent-span outcome JSDoc + add endSubagent…
doudouOUC May 31, 2026
faed8ea
fix(telemetry): strip opaque DeepSeek bot IDs + fix stale JSDoc
doudouOUC May 31, 2026
2b30cac
fix(telemetry): condition bridge skip on native subagent span presence
doudouOUC Jun 2, 2026
fbc7190
fix(telemetry): check span liveness in isInNativeSubagentSpan + minor…
doudouOUC Jun 5, 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
525 changes: 525 additions & 0 deletions docs/design/telemetry-subagent-spans-design.md

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -330,13 +330,13 @@ export async function startInteractiveUI(
<VimModeProvider settings={settings}>
<AgentViewProvider config={config}>
<BackgroundTaskViewProvider config={config}>
<AppContainer
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
initializationResult={initializationResult}
/>
<AppContainer
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
initializationResult={initializationResult}
/>
</BackgroundTaskViewProvider>
</AgentViewProvider>
</VimModeProvider>
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setLivePanelFocused(false);
return true;
}
if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
if (
key.sequence &&
key.sequence.length === 1 &&
!key.ctrl &&
!key.meta
) {
setLivePanelFocused(false);
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -951,16 +951,21 @@ export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
const selectedAgentIdForActivity =
selectedEntry?.kind === 'agent' ? selectedEntry.agentId : undefined;
useEffect(() => {
if (!dialogOpen || !isDetailMode || !selectedAgentIdForActivity)
return;
if (!dialogOpen || !isDetailMode || !selectedAgentIdForActivity) return;
const registry = config.getBackgroundTaskRegistry();
const onActivity = (entry: AgentTask) => {
if (entry.agentId !== selectedAgentIdForActivity) return;
setActivityTick((n) => n + 1);
};
registry.setActivityChangeCallback(onActivity);
return () => registry.setActivityChangeCallback(undefined);
}, [dialogOpen, dialogMode, isDetailMode, config, selectedAgentIdForActivity]);
}, [
dialogOpen,
dialogMode,
isDetailMode,
config,
selectedAgentIdForActivity,
]);

// Wall-clock tick for the running agent's duration. Activity callbacks
// fire when tools run, but duration needs to advance even when the agent
Expand Down Expand Up @@ -1021,7 +1026,14 @@ export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
) {
exitDetail();
}
}, [dialogOpen, dialogMode, isDetailMode, selectedEntryId, selectedStatus, exitDetail]);
}, [
dialogOpen,
dialogMode,
isDetailMode,
selectedEntryId,
selectedStatus,
exitDetail,
]);

// Encapsulates the cancel flow with the foreground confirm-step.
// Foreground entries: first `x` arms; second `x` confirms. Background
Expand Down
16 changes: 14 additions & 2 deletions packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ const debugLogger = createDebugLogger('BG_TASK_VIEW');

// ─── Types ──────────────────────────────────────────────────

export type BackgroundDialogMode = 'closed' | 'list' | 'detail' | 'detail-from-panel';
export type BackgroundDialogMode =
| 'closed'
| 'list'
| 'detail'
| 'detail-from-panel';

export interface BackgroundTaskViewState {
/**
Expand Down Expand Up @@ -288,7 +292,15 @@ export function BackgroundTaskViewProvider({
livePanelFocused,
livePanelSelectedIndex,
}),
[entries, selectedIndex, dialogMode, dialogOpen, pillFocused, livePanelFocused, livePanelSelectedIndex],
[
entries,
selectedIndex,
dialogMode,
dialogOpen,
pillFocused,
livePanelFocused,
livePanelSelectedIndex,
],
);

const actions: BackgroundTaskViewActions = useMemo(
Expand Down
52 changes: 52 additions & 0 deletions packages/core/src/agents/runtime/agent-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { describe, expect, it } from 'vitest';
import {
getCurrentAgentDepth,
getCurrentAgentId,
getRuntimeContentGenerator,
runWithAgentContext,
Expand Down Expand Up @@ -161,3 +162,54 @@ describe('agent-context (merging)', () => {
});
});
});

describe('agent-context (depth) — #3731 Phase 3', () => {
it('returns 0 outside any frame', () => {
expect(getCurrentAgentDepth()).toBe(0);
});

it('top-level subagent has depth 0', async () => {
await runWithAgentContext('top', async () => {
expect(getCurrentAgentDepth()).toBe(0);
});
});

it('auto-increments per nesting: top=0, child=1, grandchild=2', async () => {
await runWithAgentContext('top', async () => {
expect(getCurrentAgentDepth()).toBe(0);
await runWithAgentContext('child', async () => {
expect(getCurrentAgentDepth()).toBe(1);
await runWithAgentContext('grandchild', async () => {
expect(getCurrentAgentDepth()).toBe(2);
});
expect(getCurrentAgentDepth()).toBe(1);
});
expect(getCurrentAgentDepth()).toBe(0);
});
expect(getCurrentAgentDepth()).toBe(0);
});

it('sibling subagents at the same nesting level both see the same depth', async () => {
await runWithAgentContext('parent', async () => {
await runWithAgentContext('siblingA', async () => {
expect(getCurrentAgentDepth()).toBe(1);
});
await runWithAgentContext('siblingB', async () => {
expect(getCurrentAgentDepth()).toBe(1);
});
});
});

it('callers do not pass depth — it is computed from parent frame only', async () => {
// Defensive: confirm `runWithAgentContext`'s signature still takes
// only (agentId, fn). Phase 3 depth tracking must remain a
// caller-invisible internal concern.
await runWithAgentContext('outer', async () => {
const before = getCurrentAgentDepth();
// No way to pass depth in — the helper computes it.
await runWithAgentContext('inner', async () => {
expect(getCurrentAgentDepth()).toBe(before + 1);
});
});
});
});
30 changes: 29 additions & 1 deletion packages/core/src/agents/runtime/agent-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ export interface RuntimeContentGeneratorView {
interface AgentContext {
readonly agentId?: string;
readonly runtimeView?: RuntimeContentGeneratorView;
/**
* Nesting depth — 0 for a top-level subagent (called from a user's
* top-level interaction), +1 per nested `runWithAgentContext` frame.
* Auto-incremented; callers do not pass it. Read via
* {@link getCurrentAgentDepth} for telemetry (#3731 Phase 3).
*/
readonly depth?: number;
}

const storage = new AsyncLocalStorage<AgentContext>();
Expand All @@ -41,7 +48,11 @@ export function runWithAgentContext<T>(
fn: () => Promise<T>,
): Promise<T> {
const current = storage.getStore() ?? {};
return storage.run({ ...current, agentId }, fn);
// Auto-increment depth: top-level = 0, nested = parent+1. No caller has
// to know about it; telemetry reads it back via getCurrentAgentDepth
// (#3731 Phase 3 subagent spans).
const depth = (current.depth ?? -1) + 1;
return storage.run({ ...current, agentId, depth }, fn);
}

export function runWithRuntimeContentGenerator<T>(
Expand All @@ -56,6 +67,23 @@ export function getCurrentAgentId(): string | null {
return storage.getStore()?.agentId ?? null;
}

/**
* Returns the depth of the current agent context frame. 0 means we're
* inside a top-level subagent (or no subagent at all — but in that case
* the caller won't typically need this). Used by telemetry to populate
* `qwen-code.subagent.depth` on subagent spans.
*
* @remarks Returns 0 for two semantically distinct states: (a) no agent
* frame exists, and (b) a top-level frame exists with `depth=0`. Callers
* that need to discriminate MUST first check {@link getCurrentAgentId} —
* it returns `null` only in state (a). See `runWithSubagentSpan` in
* `tools/agent/agent.ts` for the canonical disambiguation pattern.
* Review wenshao @ #4410 (DeepSeek bot 3290820381).
*/
export function getCurrentAgentDepth(): number {
Comment thread
doudouOUC marked this conversation as resolved.
return storage.getStore()?.depth ?? 0;
}

export function getRuntimeContentGenerator():
| RuntimeContentGeneratorView
| undefined {
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/telemetry/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,10 @@ export const SPAN_TOOL_EXECUTION = 'qwen-code.tool.execution';
export const SPAN_TOOL_BLOCKED_ON_USER = 'qwen-code.tool.blocked_on_user';
/** Wraps each pre/post-tool-use hook fire site for per-hook latency / decision tracking. */
export const SPAN_HOOK = 'qwen-code.hook';
/**
* Wraps a single subagent invocation. Parents the LLM/tool/hook spans the
* subagent emits, so concurrent subagents (parallel AGENT tool calls) get
* isolated subtrees instead of interleaving under the parent interaction
* (#3731 Phase 3).
*/
export const SPAN_SUBAGENT = 'qwen-code.subagent';
7 changes: 7 additions & 0 deletions packages/core/src/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ export {
endToolBlockedOnUserSpan,
startHookSpan,
endHookSpan,
startSubagentSpan,
endSubagentSpan,
runInSubagentSpanContext,
getActiveInteractionSpan,
truncateSpanError,
} from './session-tracing.js';
Expand All @@ -163,6 +166,10 @@ export type {
HookEvent,
StartHookSpanOptions,
HookSpanMetadata,
SubagentInvocationKind,
SubagentStatus,
StartSubagentSpanOptions,
SubagentSpanMetadata,
} from './session-tracing.js';
export {
addUserPromptAttributes,
Expand Down
63 changes: 63 additions & 0 deletions packages/core/src/telemetry/log-to-span-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@ import type { ReadableLogRecord } from '@opentelemetry/sdk-logs';
import type { SpanExporter } from '@opentelemetry/sdk-trace-base';

let mockCurrentSessionId: string | undefined = undefined;
let mockIsInNativeSubagentSpan = false;

vi.mock('./session-context.js', () => ({
getCurrentSessionId: () => mockCurrentSessionId,
}));

vi.mock('./session-tracing.js', () => ({
isInNativeSubagentSpan: () => mockIsInNativeSubagentSpan,
}));

interface ExportedSpan {
name: string;
kind: number;
Expand Down Expand Up @@ -752,6 +757,64 @@ describe('LogToSpanProcessor', () => {
);
});

describe('bridge skip-list (#3731 Phase 3)', () => {
it('skips qwen-code.subagent_execution when native subagent span is active', async () => {
mockIsInNativeSubagentSpan = true;
const logRecord = {
body: 'subagent started',
hrTime: [2000, 0] as [number, number],
attributes: {
'event.name': 'qwen-code.subagent_execution',
subagent_name: 'Explore',
status: 'started',
},
} as unknown as ReadableLogRecord;

processor.onEmit(logRecord);
await processor.forceFlush();

expect(exportedSpans).toHaveLength(0);
mockIsInNativeSubagentSpan = false;
});

it('bridges subagent_execution when no native span is active (e.g. runForkedAgent)', async () => {
mockIsInNativeSubagentSpan = false;
const logRecord = {
body: 'forked agent started',
hrTime: [2500, 0] as [number, number],
attributes: {
'event.name': 'qwen-code.subagent_execution',
subagent_name: 'dreamAgent',
status: 'started',
},
} as unknown as ReadableLogRecord;

processor.onEmit(logRecord);
await processor.forceFlush();

expect(exportedSpans).toHaveLength(1);
expect(exportedSpans[0].name).toBe('qwen-code.subagent_execution');
});

it('still bridges other events normally (e.g. qwen-code.tool_call)', async () => {
const logRecord = {
body: 'tool call',
hrTime: [3000, 0] as [number, number],
attributes: {
'event.name': 'qwen-code.tool_call',
tool_name: 'read_file',
},
} as unknown as ReadableLogRecord;

processor.onEmit(logRecord);
await processor.forceFlush();

// Sanity check: skip list is narrow — non-listed events still bridge.
expect(exportedSpans).toHaveLength(1);
expect(exportedSpans[0].name).toBe('qwen-code.tool_call');
});
});

describe('export failure diagnostics', () => {
function makeFailingProcessor(error: Error | undefined) {
const failingExporter = {
Expand Down
23 changes: 22 additions & 1 deletion packages/core/src/telemetry/log-to-span-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,23 @@ import {
resourceFromAttributes,
} from '@opentelemetry/resources';

import { SERVICE_NAME } from './constants.js';
import { EVENT_SUBAGENT_EXECUTION, SERVICE_NAME } from './constants.js';
import {
deriveTraceId,
randomHexString,
randomSpanId,
} from './trace-id-utils.js';
import { getCurrentSessionId } from './session-context.js';
import { isInNativeSubagentSpan } from './session-tracing.js';

/**
* LogRecord event names that have native span coverage when emitted
* inside a `runInSubagentSpanContext` body. The bridge is only skipped
* when the ALS confirms a native subagent span is active — paths that
* emit the same event WITHOUT a native span (e.g. `runForkedAgent`)
* still get a bridge span so trace-tree observability is preserved.
*/
const BRIDGE_SKIP_EVENT_NAMES = new Set<string>([EVENT_SUBAGENT_EXECUTION]);
Comment thread
doudouOUC marked this conversation as resolved.
Comment thread
doudouOUC marked this conversation as resolved.
Comment thread
doudouOUC marked this conversation as resolved.

const EXPORT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_BUFFER_SIZE = 10_000;
Expand Down Expand Up @@ -138,6 +148,17 @@ export class LogToSpanProcessor implements LogRecordProcessor {
return;
}

// Skip bridge only when a native subagent span is active in the ALS.
// Paths without native coverage (e.g. runForkedAgent) still get bridged.
const eventName = logRecord.attributes?.['event.name'];
if (
typeof eventName === 'string' &&
BRIDGE_SKIP_EVENT_NAMES.has(eventName) &&
isInNativeSubagentSpan()
) {
return;
}

const name = deriveSpanName(logRecord);
const startTime = logRecord.hrTime;

Expand Down
Loading
Loading