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
b06954f
fix(telemetry): align session lifecycle with OTel
zjunothing Aug 6, 2026
e774bb6
Merge branch 'main' into fix/issue-8589-otel-session-lifecycle
wenshao Aug 6, 2026
5b9c8c6
feat(telemetry): complete session lifecycle coverage
zjunothing Aug 6, 2026
716b243
Merge branch 'main' into fix/issue-8589-otel-session-lifecycle
wenshao Aug 6, 2026
babe9db
fix(telemetry): deduplicate deferred session starts
zjunothing Aug 7, 2026
0588bf9
test(telemetry): cover duplicate session starts
zjunothing Aug 7, 2026
aba197f
fix(serve): emit daemon session starts
zjunothing Aug 7, 2026
8cbb1ed
Merge branch 'main' into fix/issue-8589-otel-session-lifecycle
qwen-code-dev-bot Aug 7, 2026
8274e0c
fix(telemetry): repair session lifecycle test wiring and record attri…
qwen-code-dev-bot Aug 7, 2026
cdfaea1
Merge branch 'main' into fix/issue-8589-otel-session-lifecycle
qwen-code-dev-bot Aug 8, 2026
05859b8
fix(telemetry): skip session lifecycle transition on same-id resume (…
qwen-code-dev-bot Aug 8, 2026
946b27a
Merge branch 'main' into fix/issue-8589-otel-session-lifecycle
qwen-code-dev-bot Aug 8, 2026
95e2b18
test(telemetry): pin session lifecycle behaviors per review (#8616)
qwen-code-dev-bot Aug 8, 2026
7e30622
Merge branch 'main' into fix/issue-8589-otel-session-lifecycle
qwen-code-dev-bot Aug 8, 2026
2ac0d47
fix(telemetry): emit session start catch-up on every init path (#8616)
qwen-code-dev-bot Aug 8, 2026
2b160e0
Merge branch 'main' into fix/issue-8589-otel-session-lifecycle
qwen-code-dev-bot Aug 8, 2026
33916e2
Merge remote-tracking branch 'origin/main' into fix/issue-8589-otel-s…
qwen-code-dev-bot Aug 9, 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
100 changes: 100 additions & 0 deletions docs/design/otel-session-lifecycle-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# OpenTelemetry Session Lifecycle

## Status

Implemented in issue #8589.

## Scope

Qwen Code already records the application session ID as `session.id` and maps
it to `gen_ai.conversation.id` on GenAI LLM and agent spans. This design adds
the OpenTelemetry General Session lifecycle events without removing the
existing Qwen-specific telemetry fields or event names.

The implementation follows the Development-status General Session semantic
conventions at:

<https://opentelemetry.io/docs/specs/semconv/general/session/>

The GenAI conversation mapping follows:

<https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-agent-spans.md>

## Event representation

The standard lifecycle events are emitted as OpenTelemetry LogRecords with
the required `event.name` attribute:

| Event | Required attributes | Emission point |
| --------------- | ------------------- | -------------------------------------------------------- |
| `session.start` | `session.id` | Initial `Config` initialization and every session switch |
| `session.end` | `session.id` | Session switch and telemetry shutdown |

The existing `qwen-code.config` / `cli_config` and RUM `session_start` events
remain unchanged for backward compatibility. The standard records are
additive and are emitted through the configured OpenTelemetry logs pipeline.

## Session continuation

`Config.startNewSession()` is used for both replacing the current conversation
(`/clear`, `/new`) and resuming a persisted conversation. A persisted
`sessionData` argument identifies the latter continuation case. On a
continuation, the new `session.start` record includes
`session.previous_id`; replacement sessions do not claim continuation.

The outgoing session is ended before the new session starts. Resuming the
session the user is already in (same `session.id`) records no lifecycle
transition at all. Telemetry shutdown ends the currently active session
before shutting down the SDK.

## Session id reuse on `/resume`

Qwen Code's session model predates this design: `/resume` restores a
persisted conversation under its original session id instead of minting a new
one. Two consequences follow for the lifecycle stream:

- A resumed id can carry more than one disjoint
`session.start`/`session.end` window within a single process (for example:
start `A`, `/clear` to `B`, then `/resume` back to `A`).
- `session.previous_id` points from the resumed id to the session that was
active at resume time. That session may have been created _after_ the
resumed id, so lineage edges can point backwards in time and can form
cycles.

This is the reverse of the OTel General Session convention's id-rotation
model, in which a freshly minted id points back at the retired one. Backends
counting sessions or computing durations should key on
(`session.id`, `session.start` timestamp) windows rather than `session.id`
alone. Whether `/resume` should mint a new id instead is a session-model
decision outside this design.

## Known limitations (daemon / ACP)

Daemon-spawned ACP sessions build a fresh `Config` per session
(`loadCliConfig()`) and never flow through `Config.startNewSession()`, so in
that path today:

- a conversation session receives `session.start` from its `Config`
initialization but no `session.end` when the session is later switched or
disposed, and
- process shutdown ends the session id last recorded in the telemetry session
context — in an ACP child that is the boot-time session, not the
conversation session.

A single ACP child can also host several concurrent sessions, which the
single process-level "current session" tracked by the context cannot
represent. Closing this gap requires lifecycle design for multi-session
processes and is deferred to a follow-up.

## Compatibility and safety

- `session.id` remains on existing spans and logs.
- `gen_ai.conversation.id` remains the session correlation field for GenAI
spans.
- `session.previous_id` is emitted only when the application has an explicit
persisted continuation, and it is never equal to the new `session.id`.
- Cold-start resumptions (`--resume`, `--continue`, `--fork-session`) do not
carry `session.previous_id`; startup lineage, including the fork source, is
left to a follow-up.
- Session event emission is best-effort through the existing OTel logger and
does not block session switching or shutdown.
20 changes: 20 additions & 0 deletions docs/developers/development/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,12 @@ The following events are logged:
- `qwen-code.config`: Emitted once at startup with CLI configuration.
- **Attributes**: `model`, `sandbox_enabled`, `core_tools_enabled`, `approval_mode`, `file_filtering_respect_git_ignore`, `debug_mode`, `truncate_tool_output_threshold`, `truncate_tool_output_lines`, `hooks` (comma-separated, omitted if disabled), `ide_enabled`, `interactive_shell_enabled`, `mcp_servers`, `mcp_servers_count`, `mcp_tools`, `mcp_tools_count`, `output_format`, `skills`, `subagents`

- `session.start`: A session begins. Emitted after telemetry initialization at startup and again on every session switch; lifecycle semantics are described in the Spans section.
- **Attributes**: `session.id` (string), `session.previous_id` (string, present only when this start continues a persisted conversation under a new session id)

- `session.end`: A session ends. Emitted before a session switch replaces the current session, and at telemetry shutdown.
- **Attributes**: `session.id` (string)

- `qwen-code.user_prompt`: User submits a prompt.
- **Attributes**: `prompt_length` (int), `prompt_id` (string), `prompt` (string, excluded if `log_prompts_enabled` is false), `auth_type` (string)

Expand Down Expand Up @@ -858,6 +864,20 @@ The daemon process (long-running HTTP server mode) exposes its own metrics.

Distributed tracing spans form a tree rooted at `qwen-code.interaction`. Each interaction is a trace root with its own `traceId`; cross-prompt correlation uses the `session.id` attribute.

Session lifecycle is also exported through the OpenTelemetry General Session
semantic conventions. When the OTel logs pipeline is enabled, Qwen Code emits
`session.start` and `session.end` log events with the required `session.id`
attribute (cataloged under Core Session Events above). A resumed persisted
conversation includes `session.previous_id` on its `session.start` event only
when the resumed session id differs from the current one; cold-start
resumptions (`--resume`, `--continue`, `--fork-session`) do not carry it.
`/clear` and other replacement flows intentionally do not claim continuation
because they discard the previous conversation.

The existing Qwen-specific `qwen-code.config`/`cli_config` and RUM
`session_start` records remain available for compatibility. GenAI request
spans continue to use `gen_ai.conversation.id` for the same owning session ID.

- `qwen-code.interaction`: Root span for each user prompt turn.
- **Attributes**: `session.id`, optional ARMS extension `gen_ai.user.id`, `qwen-code.prompt_id`, `qwen-code.message_type`, `qwen-code.model`, `qwen-code.approval_mode`, `interaction.sequence`, `interaction.duration_ms`, `qwen-code.turn_status` ("ok"/"error"/"cancelled")

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/config/config-session-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ vi.mock('../telemetry/index.js', () => ({
isTelemetrySdkInitialized: vi.fn().mockReturnValue(false),
shutdownTelemetry: vi.fn().mockResolvedValue(undefined),
refreshSessionContext: vi.fn(),
logSessionEnd: vi.fn(),
}));
vi.mock('../core/contentGenerator.js', () => ({
resolveContentGeneratorConfigWithSources: vi.fn().mockReturnValue({
Expand Down
88 changes: 88 additions & 0 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {
isTelemetrySdkInitialized,
shutdownTelemetry,
refreshSessionContext,
logStartSession,
logSessionEnd,
} from '../telemetry/index.js';
import type {
ContentGenerator,
Expand Down Expand Up @@ -326,6 +328,8 @@ vi.mock('../telemetry/loggers.js', async (importOriginal) => {
return {
...actual,
logRipgrepFallback: vi.fn(),
logStartSession: vi.fn(actual.logStartSession),
logSessionEnd: vi.fn(actual.logSessionEnd),
};
});

Expand Down Expand Up @@ -2232,6 +2236,84 @@ describe('Server Config (config.ts)', () => {
});

describe('startNewSession', () => {
it('records no lifecycle transition when resuming the current session id', async () => {
const sessionId = 'same-session-id';
const config = new Config({ ...baseParams, sessionId });
await config.initialize({
skipGeminiInitialization: true,
skipHooks: true,
skipMcpDiscovery: true,
skipSkillManager: true,
skipFileCheckpointing: true,
});
vi.mocked(logSessionEnd).mockClear();
vi.mocked(logStartSession).mockClear();

config.startNewSession(sessionId, {
conversation: { messages: [] },
} as unknown as ResumedSessionData);

expect(logSessionEnd).not.toHaveBeenCalled();
expect(logStartSession).toHaveBeenCalledWith(
config,
expect.anything(),
undefined,
);
});

it('ends the outgoing session before starting a replacement without continuation', async () => {
const config = new Config({ ...baseParams });
await config.initialize({
skipGeminiInitialization: true,
skipHooks: true,
skipMcpDiscovery: true,
skipSkillManager: true,
skipFileCheckpointing: true,
});
const outgoingSessionId = config.getSessionId();
const endedSessionIds: string[] = [];
vi.mocked(logSessionEnd).mockClear();
vi.mocked(logStartSession).mockClear();
vi.mocked(logSessionEnd).mockImplementationOnce((cfg: Config) => {
endedSessionIds.push(cfg.getSessionId());
});

config.startNewSession('replacement-session');

expect(endedSessionIds).toEqual([outgoingSessionId]);
expect(logStartSession).toHaveBeenCalledWith(
config,
expect.anything(),
undefined,
);
expect(vi.mocked(logSessionEnd).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(logStartSession).mock.invocationCallOrder[0],
);
});

it('carries the outgoing session id when resuming a different persisted session', async () => {
const config = new Config({ ...baseParams });
await config.initialize({
skipGeminiInitialization: true,
skipHooks: true,
skipMcpDiscovery: true,
skipSkillManager: true,
skipFileCheckpointing: true,
});
const outgoingSessionId = config.getSessionId();
vi.mocked(logStartSession).mockClear();

config.startNewSession('resumed-session-id', {
conversation: { messages: [] },
} as unknown as ResumedSessionData);

expect(logStartSession).toHaveBeenCalledWith(
config,
expect.anything(),
outgoingSessionId,
);
});

it('rejects a session switch while the current recorder owns the writer lease', () => {
const config = new Config({ ...baseParams, chatRecording: true });
const originalSessionId = config.getSessionId();
Expand All @@ -2248,6 +2330,9 @@ describe('Server Config (config.ts)', () => {
}
).chatRecordingService = recorder;

vi.mocked(logSessionEnd).mockClear();
vi.mocked(logStartSession).mockClear();

expect(() => config.startNewSession('replacement-session')).toThrow(
expect.objectContaining({
name: 'SessionWriterUnavailableError',
Expand All @@ -2258,6 +2343,9 @@ describe('Server Config (config.ts)', () => {
expect(config.getChatRecordingService()).toBe(recorder);
expect(finalize).not.toHaveBeenCalled();
expect(flush).not.toHaveBeenCalled();
// A rejected switch must leave the live session's lifecycle untouched.
expect(logSessionEnd).not.toHaveBeenCalled();
expect(logStartSession).not.toHaveBeenCalled();
});

const resumedGoalSession = (
Expand Down
19 changes: 16 additions & 3 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ import {
shutdownTelemetry,
refreshSessionContext,
logStartSession,
logSessionEnd,
logRipgrepFallback,
RipgrepFallbackEvent,
StartSessionEvent,
Expand Down Expand Up @@ -3797,7 +3798,15 @@ export class Config {
});

const previousSessionId = this.sessionId;
this.sessionId = sessionId ?? randomUUID();
const nextSessionId = sessionId ?? randomUUID();
// Resuming the session the user is already in keeps the same id. That is
// not a lifecycle transition: ending it here would record session.end for
// a live session and pair it with a duplicate session.start.
const isSessionTransition = nextSessionId !== previousSessionId;
if (isSessionTransition) {
logSessionEnd(this);
}
this.sessionId = nextSessionId;
// Unconditional: startNewSession is only called on the canonical Config
// instance (the one that already claimed via sessionEnvClaimed), so this
// correctly updates the env var to reflect the new active session.
Expand Down Expand Up @@ -3843,7 +3852,11 @@ export class Config {
// one, and the "N-shotted" PR label would span sessions.
CommitAttributionService.resetInstance();
if (this.initialized) {
logStartSession(this, new StartSessionEvent(this));
logStartSession(
this,
new StartSessionEvent(this),
sessionData && isSessionTransition ? previousSessionId : undefined,
);
}

// Refresh the runtime.json sidecar so external observers (terminal
Expand All @@ -3859,7 +3872,7 @@ export class Config {
// sidecar that happens to share the outgoing session id
// mirrors the kimi-cli "write only when a session is
// established for this process" rule.
if (this.runtimeStatusEnabled && previousSessionId !== this.sessionId) {
if (this.runtimeStatusEnabled && isSessionTransition) {
const oldPath = this.storage.getRuntimeStatusPath(previousSessionId);
const newPath = this.storage.getRuntimeStatusPath(this.sessionId);
const cliVersion = this.cliVersion ?? null;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/telemetry/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const EVENT_API_ERROR = 'qwen-code.api_error';
export const EVENT_API_CANCEL = 'qwen-code.api_cancel';
export const EVENT_API_RESPONSE = 'qwen-code.api_response';
export const EVENT_CLI_CONFIG = 'qwen-code.config';
export const EVENT_SESSION_START = 'session.start';
export const EVENT_SESSION_END = 'session.end';
export const EVENT_EXTENSION_DISABLE = 'qwen-code.extension_disable';
export const EVENT_EXTENSION_ENABLE = 'qwen-code.extension_enable';
export const EVENT_EXTENSION_INSTALL = 'qwen-code.extension_install';
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export {
} from './config.js';
export {
logStartSession,
logSessionEnd,
logUserPrompt,
logUserRetry,
logToolCall,
Expand Down
Loading
Loading