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
19 changes: 15 additions & 4 deletions docs/ACCEPTANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
39 changes: 29 additions & 10 deletions hypaware-core/plugins-workspace/claude/src/telemetry/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}
Expand All @@ -125,9 +133,10 @@ export function flattenClaudeTelemetryMetrics(data) {
* @param {string} name
* @param {string | undefined} unit
* @param {unknown} value one OTLP `NumberDataPoint`
* @param {Record<string, unknown>} 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)
Expand All @@ -142,6 +151,7 @@ function eventFromDataPoint(name, unit, value) {
name,
attributes,
...(timestamp ? { timestamp } : {}),
...(hasKeys(resource) ? { resource } : {}),
}
}

Expand All @@ -164,9 +174,10 @@ function metricDataPoints(metric) {

/**
* @param {unknown} value
* @param {Record<string, unknown>} 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)
Expand All @@ -181,6 +192,7 @@ function eventFromRecord(value) {
attributes,
...(timestamp ? { timestamp } : {}),
...(sequence !== undefined ? { sequence } : {}),
...(hasKeys(resource) ? { resource } : {}),
}
}

Expand Down Expand Up @@ -231,15 +243,22 @@ function decodeAnyValue(value) {
}

/**
* @param {unknown} resource
* @param {Record<string, unknown>} 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<string, unknown>} 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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions hypaware-core/plugins-workspace/claude/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ export interface ClaudeTelemetryEvent {
sequence?: number
/** Every attribute on the record, unwrapped. */
attributes: Record<string, unknown>
/**
* 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<string, unknown>
}

/**
Expand Down
55 changes: 55 additions & 0 deletions test/plugins/claude-telemetry-listener.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading