diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index e033751b..c9ded0f6 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -704,7 +704,12 @@ two-layer drift detection this discharges), `with_tools` above zero (that is the body join, step 4's fields arriving in columns); `with_cwd` and `with_branch` above zero (that is the SessionStart hook, which is where cwd and git identity come from on this path, not the - events); `with_version` above zero (`app.version` off the events). + events); `with_version` above zero (`app.version` off the events on + 2.1.233, or `service.version` off the export's OTLP resource from 2.1.235, + where the event attribute is gone: the projector reads both, so a null + here across a whole session means the version reached neither place: a + third upstream shape or a broken fallback, filed as new drift the way + #854 was, not #854 itself). `with_cwd = 0` with everything else healthy means the hook is not installed and the usage policy is running blind, which is a release blocker on its own. @@ -756,9 +761,15 @@ two-layer drift detection this discharges), `model`, `input_tokens`, `output_tokens`, and the cache-token pair; the `permission_mode_changed` row's `attributes` carry `from_mode` and `to_mode`. Every row's `attributes` should carry the identity block - (`app.version`, `app.entrypoint`, `user.account_uuid`, `organization.id`, - `terminal.type`). Pass `--max-bytes 0` or the display truncates the JSON and - you will read a short value as a missing one. + (`user.account_uuid`, `organization.id`, `terminal.type`, plus + `app.version` and `app.entrypoint` on clients that still send them: 2.1.235 + moved the version to the OTLP resource, so its absence here is upstream + shape, not a capture fault. That same capture carries no `app.entrypoint` + on the events either and the resource offers no replacement for it, so + `ai_gateway_messages.entrypoint` is null on that client: a separate gap + from #854, and not something this step passes or fails on). Pass + `--max-bytes 0` or the display truncates the JSON and you will read a short + value as a missing one. 9. Confirm the capture-health line agrees, which is the production half of the same duty: diff --git a/hypaware-core/plugins-workspace/claude/src/telemetry/events.js b/hypaware-core/plugins-workspace/claude/src/telemetry/events.js index 3edad744..d6ff1dea 100644 --- a/hypaware-core/plugins-workspace/claude/src/telemetry/events.js +++ b/hypaware-core/plugins-workspace/claude/src/telemetry/events.js @@ -35,6 +35,12 @@ const SELF_MARKER_KEY = 'hypaware.self' * nothing. An exporter we cannot fix from our side must not be able to * fail the request. * + * The group's decoded resource attributes ride along on every event it + * produced, because some of what a row needs is reported once per export + * rather than per record: Claude Code 2.1.235 stamps its version on the + * resource as `service.version` and no longer sends `app.version` on the + * events at all. + * * @ref LLP 0257#registration [implements]: the shared server carries the * transport; payload interpretation is claude-owned, including the * self-telemetry loop guard @@ -51,7 +57,8 @@ export function flattenClaudeTelemetryEvents(data) { for (const groupValue of groups) { const group = asObject(groupValue) if (!group) continue - if (resourceHasSelfMarker(group.resource)) continue + const resource = decodeAttributes(asObject(group.resource)?.attributes) + if (hasSelfMarker(resource)) continue const scopes = Array.isArray(group.scopeLogs) ? group.scopeLogs : [] for (const scopeValue of scopes) { const scopeLog = asObject(scopeValue) @@ -60,7 +67,7 @@ export function flattenClaudeTelemetryEvents(data) { if (!scopeName || !scopeName.startsWith(CLAUDE_EVENT_SCOPE_PREFIX)) continue const records = Array.isArray(scopeLog.logRecords) ? scopeLog.logRecords : [] for (const recordValue of records) { - const event = eventFromRecord(recordValue) + const event = eventFromRecord(recordValue, resource) if (event) events.push(event) } } @@ -96,7 +103,8 @@ export function flattenClaudeTelemetryMetrics(data) { for (const groupValue of groups) { const group = asObject(groupValue) if (!group) continue - if (resourceHasSelfMarker(group.resource)) continue + const resource = decodeAttributes(asObject(group.resource)?.attributes) + if (hasSelfMarker(resource)) continue const scopes = Array.isArray(group.scopeMetrics) ? group.scopeMetrics : [] for (const scopeValue of scopes) { const scopeMetric = asObject(scopeValue) @@ -111,7 +119,7 @@ export function flattenClaudeTelemetryMetrics(data) { if (!name) continue const unit = stringOf(metric.unit) for (const pointValue of metricDataPoints(metric)) { - const event = eventFromDataPoint(name, unit, pointValue) + const event = eventFromDataPoint(name, unit, pointValue, resource) if (event) events.push(event) } } @@ -125,9 +133,10 @@ export function flattenClaudeTelemetryMetrics(data) { * @param {string} name * @param {string | undefined} unit * @param {unknown} value one OTLP `NumberDataPoint` + * @param {Record} resource the group's decoded resource attributes * @returns {ClaudeTelemetryEvent | undefined} */ -function eventFromDataPoint(name, unit, value) { +function eventFromDataPoint(name, unit, value, resource) { const point = asObject(value) if (!point) return undefined const attributes = decodeAttributes(point.attributes) @@ -142,6 +151,7 @@ function eventFromDataPoint(name, unit, value) { name, attributes, ...(timestamp ? { timestamp } : {}), + ...(hasKeys(resource) ? { resource } : {}), } } @@ -164,9 +174,10 @@ function metricDataPoints(metric) { /** * @param {unknown} value + * @param {Record} resource the group's decoded resource attributes * @returns {ClaudeTelemetryEvent | undefined} */ -function eventFromRecord(value) { +function eventFromRecord(value, resource) { const record = asObject(value) if (!record) return undefined const attributes = decodeAttributes(record.attributes) @@ -181,6 +192,7 @@ function eventFromRecord(value) { attributes, ...(timestamp ? { timestamp } : {}), ...(sequence !== undefined ? { sequence } : {}), + ...(hasKeys(resource) ? { resource } : {}), } } @@ -231,15 +243,22 @@ function decodeAnyValue(value) { } /** - * @param {unknown} resource + * @param {Record} resource decoded resource attributes * @returns {boolean} */ -function resourceHasSelfMarker(resource) { - const attrs = decodeAttributes(asObject(resource)?.attributes) - const marker = attrs[SELF_MARKER_KEY] +function hasSelfMarker(resource) { + const marker = resource[SELF_MARKER_KEY] return marker === true || marker === 'true' } +/** + * @param {Record} value + * @returns {boolean} + */ +function hasKeys(value) { + return Object.keys(value).length > 0 +} + /** * @param {unknown} value nanoseconds since the epoch, as a string or number * @returns {string | undefined} diff --git a/hypaware-core/plugins-workspace/claude/src/telemetry/projection.js b/hypaware-core/plugins-workspace/claude/src/telemetry/projection.js index ef061fe5..2072a507 100644 --- a/hypaware-core/plugins-workspace/claude/src/telemetry/projection.js +++ b/hypaware-core/plugins-workspace/claude/src/telemetry/projection.js @@ -470,7 +470,14 @@ function sessionFacts(event) { * @param {ClaudeTelemetryEvent} event */ function mergeSessionFacts(facts, event) { - facts.clientVersion ??= stringAttr(event, 'app.version') + // Event first, resource second: Claude Code 2.1.233 put the version on + // every event as `app.version` and 2.1.235 puts it only on the export's + // resource as `service.version`, so reading both keeps one code path + // correct across the drift instead of trading one version floor for the + // other. + // @ref LLP 0262#field-parity-r1 [implements]: `client_version` is a column + // the OTEL path owes parity on, whichever place the client reports it + facts.clientVersion ??= stringAttr(event, 'app.version') ?? resourceAttr(event, 'service.version') facts.entrypoint ??= stringAttr(event, 'app.entrypoint') facts.userId ??= stringAttr(event, 'user.account_uuid') facts.organizationId ??= stringAttr(event, 'organization.id') @@ -492,6 +499,18 @@ function mergeSessionFacts(facts, event) { } } +/** + * A string fact off the export's resource rather than the event itself. + * + * @param {ClaudeTelemetryEvent} event + * @param {string} key + * @returns {string | undefined} + */ +function resourceAttr(event, key) { + const value = event.resource?.[key] + return typeof value === 'string' && value.length > 0 ? value : undefined +} + /** * @param {ClaudeTelemetryEvent} event * @param {string} key diff --git a/hypaware-core/plugins-workspace/claude/src/types.d.ts b/hypaware-core/plugins-workspace/claude/src/types.d.ts index e8ad5a32..bdd2d284 100644 --- a/hypaware-core/plugins-workspace/claude/src/types.d.ts +++ b/hypaware-core/plugins-workspace/claude/src/types.d.ts @@ -28,6 +28,14 @@ export interface ClaudeTelemetryEvent { sequence?: number /** Every attribute on the record, unwrapped. */ attributes: Record + /** + * The OTLP resource attributes of the export this event arrived in, + * unwrapped. Present only when the resource carried any. Kept separate from + * `attributes` because it describes the exporting process, not the event: + * `claude_telemetry_events` rows stay per-event, and the projector reads it + * only for facts Claude Code reports once per export (`service.version`). + */ + resource?: Record } /** diff --git a/test/plugins/claude-telemetry-listener.test.js b/test/plugins/claude-telemetry-listener.test.js index 576f315f..6d3440db 100644 --- a/test/plugins/claude-telemetry-listener.test.js +++ b/test/plugins/claude-telemetry-listener.test.js @@ -197,6 +197,61 @@ test('one turn projects to a user row and an assistant row with native uuids', ( assert.equal(assistant.model, 'claude-haiku-4-5-20251001') }) +/** + * Claude Code 2.1.235 stopped sending `app.version` on the events and + * reports its version once per export, on the OTLP resource, as + * `service.version`. The column has to survive that drift. + * + * @ref LLP 0262#field-parity-r1 [tests]: `client_version` stays populated on + * the OTEL path across the upstream shape change + */ +test('client_version falls back to the resource service.version when no event carries app.version', () => { + const records = turnRecords().map((entry) => ({ + ...entry, + attributes: entry.attributes.filter((attr) => attr.key !== 'app.version'), + })) + const events = flattenClaudeTelemetryEvents(envelope(records, { + 'service.name': 'claude-code', + 'service.version': '2.1.235', + })) + assert.equal(events.every((event) => event.attributes['app.version'] === undefined), true) + const [projection] = projectClaudeTelemetryEvents(events, { + clientName: 'claude', + usageByRequestId: new Map(), + }) + assert.equal(projection.client_version, '2.1.235') + for (const row of aiGatewayRowsFromProjectedExchange(projection)) { + assert.equal(row.client_version, '2.1.235') + } +}) + +test('an event-level app.version still outranks the resource service.version', () => { + const events = flattenClaudeTelemetryEvents(envelope(turnRecords(), { + 'service.name': 'claude-code', + 'service.version': '2.1.235', + })) + const [projection] = projectClaudeTelemetryEvents(events, { + clientName: 'claude', + usageByRequestId: new Map(), + }) + assert.equal(projection.client_version, '2.1.233') + for (const row of aiGatewayRowsFromProjectedExchange(projection)) { + assert.equal(row.client_version, '2.1.233') + } +}) + +test('a resource with no service.version leaves client_version unset rather than empty', () => { + const records = turnRecords().map((entry) => ({ + ...entry, + attributes: entry.attributes.filter((attr) => attr.key !== 'app.version'), + })) + const [projection] = projectClaudeTelemetryEvents( + flattenClaudeTelemetryEvents(envelope(records)), + { clientName: 'claude', usageByRequestId: new Map() }, + ) + assert.equal(projection.client_version, undefined) +}) + test('api_request usage lands on the assistant message it names', () => { const [projection] = projectAll(turnRecords()) const assistant = projection.messages[1]