@pepys/source-telemetry 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -2
- package/package.json +30 -4
- package/src/consumers/products/find-claude-code-model-request.ts +41 -0
- package/src/consumers/products/find-codex-model-request.ts +34 -0
- package/src/consumers/products/find-codex-turn-cost.ts +21 -0
- package/src/consumers/products/find-record-number.ts +101 -0
- package/src/consumers/products/parse-fact-type.ts +21 -0
- package/src/consumers/products/types.ts +34 -0
- package/src/consumers/telemetry-model-request.ts +91 -0
- package/src/consumers/telemetry-turn-cost.ts +85 -0
- package/src/generated/telemetry-model-request-boundary.ts +368 -0
- package/src/generated/telemetry-model-request.sql +35 -0
- package/src/generated/telemetry-turn-cost-boundary.ts +361 -0
- package/src/generated/telemetry-turn-cost.sql +26 -0
- package/src/index.ts +25 -0
- package/src/log-record-family.ts +41 -0
- package/src/otlp/format-canonical-json.ts +24 -0
- package/src/otlp/parse-logs-export.ts +71 -0
- package/src/otlp/read-otlp-facts.ts +226 -0
- package/src/otlp/receive-otlp-logs.ts +130 -0
- package/src/otlp/to-attribute-map.ts +63 -0
- package/src/otlp/types.ts +36 -0
- package/src/products/claude-code/claude-code-product.ts +45 -0
- package/src/products/codex/codex-product.ts +37 -0
- package/src/products/define-telemetry-product.ts +7 -0
- package/src/products/find-attribute-text.ts +12 -0
- package/src/products/products.ts +8 -0
- package/src/products/types.ts +21 -0
- package/src/telemetry-model-request-consumer.ts +59 -0
- package/src/telemetry-plugin.ts +42 -0
- package/src/telemetry-turn-cost-consumer.ts +42 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { parseEntityID } from '@pepys/kit';
|
|
3
|
+
import type { LogRecordPayload } from '../log-record-family.ts';
|
|
4
|
+
import type { TelemetryProduct } from '../products/types.ts';
|
|
5
|
+
import { formatCanonicalJSON } from './format-canonical-json.ts';
|
|
6
|
+
import { toAttributeMap } from './to-attribute-map.ts';
|
|
7
|
+
import type { AttributeValue, LogRecord, LogsExport, OtlpLogRecord } from './types.ts';
|
|
8
|
+
|
|
9
|
+
export interface OtlpReadOptions {
|
|
10
|
+
products: readonly TelemetryProduct[];
|
|
11
|
+
|
|
12
|
+
// the attribute that holds the session, read from the resource first and from the record second
|
|
13
|
+
sessionAttribute: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// One fact as a fact batch sender writes it. The read of the route adds the proven sender as the
|
|
17
|
+
// payload's `origin`.
|
|
18
|
+
export interface TelemetryFact {
|
|
19
|
+
type: string;
|
|
20
|
+
event: string;
|
|
21
|
+
entity: `telemetry:session:${string}`;
|
|
22
|
+
at: string;
|
|
23
|
+
payload: Omit<LogRecordPayload, 'origin'>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Why a record made no fact. A dropped record never refuses the export it came in.
|
|
27
|
+
export type DroppedRecord =
|
|
28
|
+
| { reason: 'unknown-product' }
|
|
29
|
+
|
|
30
|
+
// neither the session attribute nor the product's own session attribute has a value
|
|
31
|
+
| { reason: 'no-session'; product: string; event: string }
|
|
32
|
+
| { reason: 'no-time'; product: string; event: string };
|
|
33
|
+
|
|
34
|
+
export interface OtlpFacts {
|
|
35
|
+
facts: readonly TelemetryFact[];
|
|
36
|
+
dropped: readonly DroppedRecord[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Pure: one fact per log record of a known product, with an ID that depends on the record alone.
|
|
40
|
+
export function readOtlpFacts(body: LogsExport, options: OtlpReadOptions): OtlpFacts {
|
|
41
|
+
const facts: TelemetryFact[] = [];
|
|
42
|
+
const dropped: DroppedRecord[] = [];
|
|
43
|
+
|
|
44
|
+
for (const resourceLogs of body.resourceLogs) {
|
|
45
|
+
const resource = toAttributeMap(resourceLogs.resource.attributes);
|
|
46
|
+
|
|
47
|
+
for (const scopeLogs of resourceLogs.scopeLogs) {
|
|
48
|
+
for (const raw of scopeLogs.logRecords) {
|
|
49
|
+
const record: LogRecord = {
|
|
50
|
+
scope: { name: scopeLogs.scope.name },
|
|
51
|
+
resource,
|
|
52
|
+
attributes: toAttributeMap(raw.attributes ?? []),
|
|
53
|
+
raw,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const built = buildFact(record, options);
|
|
57
|
+
|
|
58
|
+
if ('reason' in built) {
|
|
59
|
+
dropped.push(built);
|
|
60
|
+
} else {
|
|
61
|
+
facts.push({
|
|
62
|
+
...built,
|
|
63
|
+
payload: {
|
|
64
|
+
resource: { attributes: [...resourceLogs.resource.attributes] },
|
|
65
|
+
scope: scopeLogs.scope,
|
|
66
|
+
logRecord: raw,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { facts, dropped };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function buildFact(
|
|
78
|
+
record: LogRecord,
|
|
79
|
+
options: OtlpReadOptions,
|
|
80
|
+
): Omit<TelemetryFact, 'payload'> | DroppedRecord {
|
|
81
|
+
const owner = findOwner(record, options.products);
|
|
82
|
+
|
|
83
|
+
if (owner === undefined) {
|
|
84
|
+
return { reason: 'unknown-product' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const product = owner.product;
|
|
88
|
+
const event = owner.event;
|
|
89
|
+
const attribute = product.agentSessionAttribute;
|
|
90
|
+
const agentSession = toText(record.attributes[attribute]) ?? toText(record.resource[attribute]);
|
|
91
|
+
|
|
92
|
+
const session =
|
|
93
|
+
toText(record.resource[options.sessionAttribute]) ??
|
|
94
|
+
toText(record.attributes[options.sessionAttribute]) ??
|
|
95
|
+
agentSession;
|
|
96
|
+
|
|
97
|
+
const entity = session === undefined ? undefined : toSessionEntity(session);
|
|
98
|
+
|
|
99
|
+
if (entity === undefined) {
|
|
100
|
+
return { reason: 'no-session', product: product.name, event };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const at = pickTime(record, product.timeAttribute);
|
|
104
|
+
|
|
105
|
+
if (at === undefined) {
|
|
106
|
+
return { reason: 'no-time', product: product.name, event };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const isListed = Object.hasOwn(product.events, event);
|
|
110
|
+
const eventName = `${product.name}.${event}`;
|
|
111
|
+
const key = isListed ? product.events[event]?.key?.(record) : undefined;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
type: `telemetry.${product.name}.${isListed ? event : 'unlisted'}`,
|
|
115
|
+
event: `${eventName}:${key ?? `h-${buildRecordHash(record.raw, eventName, agentSession)}`}`,
|
|
116
|
+
entity,
|
|
117
|
+
at,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function findOwner(
|
|
122
|
+
record: LogRecord,
|
|
123
|
+
list: readonly TelemetryProduct[],
|
|
124
|
+
): { product: TelemetryProduct; event: string } | undefined {
|
|
125
|
+
for (const product of list) {
|
|
126
|
+
const event = product.readEvent(record);
|
|
127
|
+
|
|
128
|
+
if (event !== undefined) {
|
|
129
|
+
return { product, event };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function toText(value: AttributeValue | undefined): string | undefined {
|
|
137
|
+
if (typeof value === 'number') {
|
|
138
|
+
return String(value);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function toSessionEntity(session: string): `telemetry:session:${string}` | undefined {
|
|
145
|
+
const entity = `telemetry:session:${session}` as const;
|
|
146
|
+
|
|
147
|
+
return parseEntityID(entity).ok ? entity : undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The time the product wrote, as ISO text in UTC; never the time ingest read the record.
|
|
151
|
+
function pickTime(record: LogRecord, timeAttribute: string): string | undefined {
|
|
152
|
+
const written = record.attributes[timeAttribute];
|
|
153
|
+
const writtenMs = typeof written === 'string' ? Date.parse(written) : Number.NaN;
|
|
154
|
+
|
|
155
|
+
if (Number.isFinite(writtenMs)) {
|
|
156
|
+
return toISOTime(writtenMs);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return toTimeFromNanoseconds(record.raw['timeUnixNano']);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A fact's time is ISO text with a four-digit year, which the envelope check takes.
|
|
163
|
+
function toISOTime(timeMs: number): string | undefined {
|
|
164
|
+
const date = new Date(timeMs);
|
|
165
|
+
|
|
166
|
+
if (!Number.isFinite(date.getTime())) {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const text = date.toISOString();
|
|
171
|
+
|
|
172
|
+
return /^\d{4}-/u.test(text) ? text : undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const nanosecondsPerMillisecond = 1_000_000n;
|
|
176
|
+
|
|
177
|
+
// OTLP JSON writes the time as decimal text, and an exporter may write a number; 0 means the time
|
|
178
|
+
// is unknown.
|
|
179
|
+
function toTimeFromNanoseconds(value: unknown): string | undefined {
|
|
180
|
+
const nanoseconds = toNanoseconds(value);
|
|
181
|
+
|
|
182
|
+
if (nanoseconds === undefined || nanoseconds === 0n) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return toISOTime(Number(nanoseconds / nanosecondsPerMillisecond));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function toNanoseconds(value: unknown): bigint | undefined {
|
|
190
|
+
if (typeof value === 'number') {
|
|
191
|
+
return Number.isInteger(value) && value >= 0 ? BigInt(value) : undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return typeof value === 'string' && /^\d+$/u.test(value) ? BigInt(value) : undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// What the product wrote: its own session ID, the event name, the time, the body, and the
|
|
198
|
+
// attributes sorted by name. The resource and the observed time stay out, because a sink that
|
|
199
|
+
// forwards a record may set both again.
|
|
200
|
+
function buildRecordHash(
|
|
201
|
+
raw: OtlpLogRecord,
|
|
202
|
+
eventName: string,
|
|
203
|
+
agentSession: string | undefined,
|
|
204
|
+
): string {
|
|
205
|
+
const attributes = (raw.attributes ?? []).toSorted((left, right) =>
|
|
206
|
+
compareText(left.key, right.key),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const hashed = formatCanonicalJSON([
|
|
210
|
+
agentSession ?? null,
|
|
211
|
+
eventName,
|
|
212
|
+
raw['timeUnixNano'] ?? null,
|
|
213
|
+
raw['body'] ?? null,
|
|
214
|
+
attributes,
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
return createHash('sha256').update(hashed).digest('hex');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function compareText(left: string, right: string): number {
|
|
221
|
+
if (left === right) {
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return left < right ? -1 : 1;
|
|
226
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { buildEventFactID } from '@pepys/kit';
|
|
2
|
+
import type {
|
|
3
|
+
DeliveryCheck,
|
|
4
|
+
EntityID,
|
|
5
|
+
FactFamily,
|
|
6
|
+
PendingFact,
|
|
7
|
+
ReceivePath,
|
|
8
|
+
Received,
|
|
9
|
+
Sender,
|
|
10
|
+
SourceContext,
|
|
11
|
+
StreamName,
|
|
12
|
+
} from '@pepys/kit';
|
|
13
|
+
import { toTagged } from '@zgeoff/std';
|
|
14
|
+
import invariant from 'tiny-invariant';
|
|
15
|
+
import { parseLogsExport } from './parse-logs-export.ts';
|
|
16
|
+
import { readOtlpFacts } from './read-otlp-facts.ts';
|
|
17
|
+
import type { DroppedRecord, OtlpReadOptions, TelemetryFact } from './read-otlp-facts.ts';
|
|
18
|
+
|
|
19
|
+
type OtlpLogsReceiver = Pick<
|
|
20
|
+
ReceivePath,
|
|
21
|
+
| 'route'
|
|
22
|
+
| 'ignoredRoutes'
|
|
23
|
+
| 'bodyByteLimit'
|
|
24
|
+
| 'factLimit'
|
|
25
|
+
| 'encodings'
|
|
26
|
+
| 'answer'
|
|
27
|
+
| 'reach'
|
|
28
|
+
| 'sender'
|
|
29
|
+
> &
|
|
30
|
+
OtlpReadOptions & { check: DeliveryCheck };
|
|
31
|
+
|
|
32
|
+
// The receive path of OTLP/HTTP logs in JSON. Ingest has checked the method, the size, the token
|
|
33
|
+
// and the sender, and has unpacked a gzip body, before `read` sees the export.
|
|
34
|
+
export function receiveOtlpLogs(receiver: OtlpLogsReceiver): ReceivePath {
|
|
35
|
+
const { products, sessionAttribute, ...path } = receiver;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
...path,
|
|
39
|
+
kind: 'receive',
|
|
40
|
+
read: (delivery, source) => {
|
|
41
|
+
if (!isJSONContentType(delivery.headers.get('content-type'))) {
|
|
42
|
+
return {
|
|
43
|
+
kind: 'refused',
|
|
44
|
+
status: 400,
|
|
45
|
+
reason: 'the body must be OTLP JSON, with the content type application/json',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const body = parseLogsExport(parseJSON(new TextDecoder().decode(delivery.body)));
|
|
50
|
+
|
|
51
|
+
if (body === undefined) {
|
|
52
|
+
return { kind: 'refused', status: 400, reason: 'the body is no OTLP logs export' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ingest calls `read` on a path with a `sender` only once a sender is proven
|
|
56
|
+
invariant(delivery.sender !== undefined, 'a telemetry export always has a proven sender');
|
|
57
|
+
|
|
58
|
+
const read = readOtlpFacts(body, { products, sessionAttribute });
|
|
59
|
+
|
|
60
|
+
if (read.dropped.length > 0) {
|
|
61
|
+
console.warn(`the source ${source.name} dropped log records`, formatDropped(read.dropped));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return buildReceived(read.facts, delivery.sender, source);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isJSONContentType(contentType: string | null): boolean {
|
|
70
|
+
return contentType?.split(';')[0]?.trim().toLowerCase() === 'application/json';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseJSON(text: string): unknown {
|
|
74
|
+
try {
|
|
75
|
+
const value: unknown = JSON.parse(text);
|
|
76
|
+
|
|
77
|
+
return value;
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// One line per export: the count of each reason, never the records.
|
|
84
|
+
function formatDropped(dropped: readonly DroppedRecord[]): string {
|
|
85
|
+
const counts = new Map<string, number>();
|
|
86
|
+
|
|
87
|
+
for (const record of dropped) {
|
|
88
|
+
counts.set(record.reason, (counts.get(record.reason) ?? 0) + 1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return JSON.stringify(Object.fromEntries(counts));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildReceived(
|
|
95
|
+
facts: readonly TelemetryFact[],
|
|
96
|
+
sender: Sender,
|
|
97
|
+
source: SourceContext,
|
|
98
|
+
): Received {
|
|
99
|
+
if (facts.length === 0) {
|
|
100
|
+
return { kind: 'ignored' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const pending: PendingFact[] = [];
|
|
104
|
+
|
|
105
|
+
for (const fact of facts) {
|
|
106
|
+
const family = findFamily(source.families, fact.type);
|
|
107
|
+
|
|
108
|
+
invariant(family !== undefined, `the source has a family for ${fact.type}`);
|
|
109
|
+
|
|
110
|
+
pending.push({
|
|
111
|
+
stream: toTagged<StreamName>(family.stream),
|
|
112
|
+
fact: {
|
|
113
|
+
id: buildEventFactID({ source: source.name, event: fact.event }),
|
|
114
|
+
type: fact.type,
|
|
115
|
+
v: 1,
|
|
116
|
+
api: family.api,
|
|
117
|
+
entity: toTagged<EntityID>(fact.entity),
|
|
118
|
+
at: fact.at,
|
|
119
|
+
payload: { ...fact.payload, origin: { issuer: sender.issuer, subject: sender.subject } },
|
|
120
|
+
},
|
|
121
|
+
blobs: [],
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { kind: 'batch', batch: { facts: pending } };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function findFamily(families: readonly FactFamily[], type: string): FactFamily | undefined {
|
|
129
|
+
return families.find((family) => Object.hasOwn(family.types, type));
|
|
130
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { isRecord } from '@zgeoff/std';
|
|
2
|
+
import type { AttributeValue, OtlpKeyValue } from './types.ts';
|
|
3
|
+
|
|
4
|
+
// The scalar attributes as a plain map. OTLP JSON writes a 64-bit integer as text, and an exporter
|
|
5
|
+
// may write any number as text, so an `intValue` that is a safe integer becomes a number. A list or
|
|
6
|
+
// a map value has no entry.
|
|
7
|
+
export function toAttributeMap(
|
|
8
|
+
attributes: readonly OtlpKeyValue[],
|
|
9
|
+
): Readonly<Record<string, AttributeValue>> {
|
|
10
|
+
return Object.fromEntries(
|
|
11
|
+
attributes.flatMap((attribute) => {
|
|
12
|
+
const value = toAttributeValue(attribute.value);
|
|
13
|
+
|
|
14
|
+
return value === undefined ? [] : [[attribute.key, value] as const];
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function toAttributeValue(value: unknown): AttributeValue | undefined {
|
|
20
|
+
if (!isRecord(value)) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const text = value['stringValue'];
|
|
25
|
+
const integer = value['intValue'];
|
|
26
|
+
const double = value['doubleValue'];
|
|
27
|
+
const flag = value['boolValue'];
|
|
28
|
+
|
|
29
|
+
if (typeof text === 'string') {
|
|
30
|
+
return text;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (typeof integer === 'number' || typeof integer === 'string') {
|
|
34
|
+
return toInteger(integer);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (typeof double === 'number' || typeof double === 'string') {
|
|
38
|
+
return toDouble(double);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return typeof flag === 'boolean' ? flag : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toInteger(value: number | string): AttributeValue {
|
|
45
|
+
if (typeof value === 'string' && !/^-?\d+$/u.test(value)) {
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const number = Number(value);
|
|
50
|
+
|
|
51
|
+
return Number.isSafeInteger(number) ? number : String(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// OTLP JSON writes NaN and the infinities as text.
|
|
55
|
+
function toDouble(value: number | string): AttributeValue | undefined {
|
|
56
|
+
if (typeof value === 'string' && value.trim() === '') {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const number = Number(value);
|
|
61
|
+
|
|
62
|
+
return Number.isFinite(number) ? number : undefined;
|
|
63
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The body of an OTLP/HTTP logs export in its JSON form. Only the fields ingest reads are typed.
|
|
2
|
+
export interface LogsExport {
|
|
3
|
+
resourceLogs: readonly ResourceLogs[];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface ResourceLogs {
|
|
7
|
+
resource: { attributes: readonly OtlpKeyValue[] };
|
|
8
|
+
scopeLogs: readonly ScopeLogs[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ScopeLogs {
|
|
12
|
+
scope: { name: string; version?: string | undefined };
|
|
13
|
+
logRecords: readonly OtlpLogRecord[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface OtlpKeyValue {
|
|
17
|
+
key: string;
|
|
18
|
+
value: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A record as the exporter wrote it. OTLP JSON writes a 64-bit integer as text, and an exporter
|
|
22
|
+
// may write it as a number, so each field the exporter owns stays as it came.
|
|
23
|
+
export interface OtlpLogRecord {
|
|
24
|
+
readonly [field: string]: unknown;
|
|
25
|
+
attributes?: OtlpKeyValue[] | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type AttributeValue = string | number | boolean;
|
|
29
|
+
|
|
30
|
+
// What a product's functions get: one record with its attributes as plain maps. Never stored.
|
|
31
|
+
export interface LogRecord {
|
|
32
|
+
scope: { name: string };
|
|
33
|
+
resource: Readonly<Record<string, AttributeValue>>;
|
|
34
|
+
attributes: Readonly<Record<string, AttributeValue>>;
|
|
35
|
+
raw: OtlpLogRecord;
|
|
36
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { LogRecord } from '../../otlp/types.ts';
|
|
2
|
+
import { defineTelemetryProduct } from '../define-telemetry-product.ts';
|
|
3
|
+
import { findAttributeText } from '../find-attribute-text.ts';
|
|
4
|
+
|
|
5
|
+
const scopePrefix = 'com.anthropic.claude_code';
|
|
6
|
+
const eventPrefix = 'claude_code.';
|
|
7
|
+
|
|
8
|
+
export const claudeCodeProduct = defineTelemetryProduct({
|
|
9
|
+
name: 'claude_code',
|
|
10
|
+
readEvent: findClaudeCodeEvent,
|
|
11
|
+
agentSessionAttribute: 'session.id',
|
|
12
|
+
timeAttribute: 'event.timestamp',
|
|
13
|
+
events: {
|
|
14
|
+
// `api_request` and `api_error` can share a request ID; the event name is in the fact ID
|
|
15
|
+
api_request: { key: findRequestKey },
|
|
16
|
+
api_error: { key: findRequestKey },
|
|
17
|
+
api_refusal: { key: (record) => findAttributeText(record, 'request_id') },
|
|
18
|
+
user_prompt: {},
|
|
19
|
+
assistant_response: {},
|
|
20
|
+
tool_result: {},
|
|
21
|
+
tool_decision: {},
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Neither the scope name nor the event name's prefix on the wire is checked against a capture, so
|
|
26
|
+
// either one marks a record as Claude Code's, and the event is the name without the prefix.
|
|
27
|
+
function findClaudeCodeEvent(record: LogRecord): string | undefined {
|
|
28
|
+
const name = findAttributeText(record, 'event.name');
|
|
29
|
+
|
|
30
|
+
if (name === undefined) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (name.startsWith(eventPrefix)) {
|
|
35
|
+
const event = name.slice(eventPrefix.length);
|
|
36
|
+
|
|
37
|
+
return event === '' ? undefined : event;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return record.scope.name.startsWith(scopePrefix) ? name : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function findRequestKey(record: LogRecord): string | undefined {
|
|
44
|
+
return findAttributeText(record, 'request_id') ?? findAttributeText(record, 'client_request_id');
|
|
45
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { LogRecord } from '../../otlp/types.ts';
|
|
2
|
+
import { defineTelemetryProduct } from '../define-telemetry-product.ts';
|
|
3
|
+
import { findAttributeText } from '../find-attribute-text.ts';
|
|
4
|
+
|
|
5
|
+
const eventPrefix = 'codex.';
|
|
6
|
+
|
|
7
|
+
export const codexProduct = defineTelemetryProduct({
|
|
8
|
+
name: 'codex',
|
|
9
|
+
readEvent: findCodexEvent,
|
|
10
|
+
agentSessionAttribute: 'conversation.id',
|
|
11
|
+
timeAttribute: 'event.timestamp',
|
|
12
|
+
|
|
13
|
+
// Any other `codex.*` event is stored as `telemetry.codex.unlisted`. With no `key`, the fact ID
|
|
14
|
+
// holds a hash of what Codex wrote.
|
|
15
|
+
events: {
|
|
16
|
+
sse_event: {},
|
|
17
|
+
api_request: {},
|
|
18
|
+
turn_cost: { key: (record) => findAttributeText(record, 'turn.id') },
|
|
19
|
+
conversation_starts: {},
|
|
20
|
+
user_prompt: {},
|
|
21
|
+
tool_decision: {},
|
|
22
|
+
tool_result: {},
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Codex puts the full name in `event.name`, such as `codex.sse_event`.
|
|
27
|
+
function findCodexEvent(record: LogRecord): string | undefined {
|
|
28
|
+
const name = findAttributeText(record, 'event.name');
|
|
29
|
+
|
|
30
|
+
if (name === undefined || !name.startsWith(eventPrefix)) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const event = name.slice(eventPrefix.length);
|
|
35
|
+
|
|
36
|
+
return event === '' ? undefined : event;
|
|
37
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { LogRecord } from '../otlp/types.ts';
|
|
2
|
+
|
|
3
|
+
// The record's attribute as text: a number counts, since an exporter may write an ID either way.
|
|
4
|
+
export function findAttributeText(record: LogRecord, name: string): string | undefined {
|
|
5
|
+
const value = record.attributes[name];
|
|
6
|
+
|
|
7
|
+
if (typeof value === 'string') {
|
|
8
|
+
return value === '' ? undefined : value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return typeof value === 'number' ? String(value) : undefined;
|
|
12
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { claudeCodeProduct } from './claude-code/claude-code-product.ts';
|
|
2
|
+
import { codexProduct } from './codex/codex-product.ts';
|
|
3
|
+
|
|
4
|
+
// The one list of products. A third product is one more entry here, and one more case in each
|
|
5
|
+
// consumer, which does not compile until the case is there.
|
|
6
|
+
export const products = [claudeCodeProduct, codexProduct] as const;
|
|
7
|
+
|
|
8
|
+
export type ProductName = (typeof products)[number]['name'];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { LogRecord } from '../otlp/types.ts';
|
|
2
|
+
|
|
3
|
+
// The ingest half of a product. It holds no row shape.
|
|
4
|
+
export interface TelemetryProduct<Name extends string = string, Event extends string = string> {
|
|
5
|
+
// the `product` column, and the middle part of every fact type
|
|
6
|
+
readonly name: Name;
|
|
7
|
+
|
|
8
|
+
// the short event name when the record is this product's; the first product that answers owns it
|
|
9
|
+
readonly readEvent: (record: LogRecord) => string | undefined;
|
|
10
|
+
|
|
11
|
+
// the attribute that holds the agent's own session ID
|
|
12
|
+
readonly agentSessionAttribute: string;
|
|
13
|
+
|
|
14
|
+
// the attribute that holds the time the product wrote; `timeUnixNano` when it is absent
|
|
15
|
+
readonly timeAttribute: string;
|
|
16
|
+
|
|
17
|
+
// the events with a fact type of their own; `key` gives the event's own key for the fact ID
|
|
18
|
+
readonly events: Readonly<
|
|
19
|
+
Record<Event, { readonly key?: ((record: LogRecord) => string | undefined) | undefined }>
|
|
20
|
+
>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { defineConsumer, defineTable } from '@pepys/kit';
|
|
2
|
+
import { logRecordFamily } from './log-record-family.ts';
|
|
3
|
+
|
|
4
|
+
// Fact mode: one row comes from exactly one fact, and the row's key comes from the fact's ID, so
|
|
5
|
+
// the order of the facts changes nothing.
|
|
6
|
+
export const telemetryModelRequestConsumer = defineConsumer({
|
|
7
|
+
name: 'telemetry_model_request',
|
|
8
|
+
entry: new URL('consumers/telemetry-model-request.ts', import.meta.url),
|
|
9
|
+
input: { mode: 'fact', streams: [logRecordFamily.stream] },
|
|
10
|
+
tables: {
|
|
11
|
+
telemetry_model_request: defineTable({
|
|
12
|
+
columns: {
|
|
13
|
+
// `telemetry:model_request:<product>.<event>:<key>`
|
|
14
|
+
id: { kind: 'entityID' },
|
|
15
|
+
|
|
16
|
+
// The join key: `telemetry:session:<atc session ID>`, or the agent's own session ID when
|
|
17
|
+
// the record has no atc session ID.
|
|
18
|
+
session_id: { kind: 'entityID' },
|
|
19
|
+
|
|
20
|
+
// The agent's own session ID. It changes inside one atc session, so nothing joins on it.
|
|
21
|
+
agent_session: { kind: 'text', nullable: true },
|
|
22
|
+
product: { kind: 'text' },
|
|
23
|
+
model: { kind: 'text' },
|
|
24
|
+
|
|
25
|
+
// never holds a token that the cache read or wrote, for every product
|
|
26
|
+
input_tokens: { kind: 'integer' },
|
|
27
|
+
cache_read_tokens: { kind: 'integer' },
|
|
28
|
+
cache_creation_tokens: { kind: 'integer' },
|
|
29
|
+
output_tokens: { kind: 'integer' },
|
|
30
|
+
|
|
31
|
+
// null when the product reports no cost for one request; 0 never stands for unknown
|
|
32
|
+
cost_usd: { kind: 'real', nullable: true },
|
|
33
|
+
cost_basis: { kind: 'text' },
|
|
34
|
+
duration_ms: { kind: 'integer', nullable: true },
|
|
35
|
+
agent: { kind: 'text', nullable: true },
|
|
36
|
+
|
|
37
|
+
// the proven sender of the export that carried the record
|
|
38
|
+
origin_issuer: { kind: 'text' },
|
|
39
|
+
origin_subject: { kind: 'text' },
|
|
40
|
+
at: { kind: 'timestamp' },
|
|
41
|
+
},
|
|
42
|
+
primaryKey: ['id'],
|
|
43
|
+
indexes: [{ columns: ['session_id'] }, { columns: ['at'] }],
|
|
44
|
+
}),
|
|
45
|
+
},
|
|
46
|
+
entityViews: [
|
|
47
|
+
{
|
|
48
|
+
table: 'telemetry_model_request',
|
|
49
|
+
kind: 'telemetry:model_request',
|
|
50
|
+
id: 'id',
|
|
51
|
+
title: 'model',
|
|
52
|
+
url: null,
|
|
53
|
+
actor: null,
|
|
54
|
+
updatedAt: 'at',
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
reads: [],
|
|
58
|
+
links: [],
|
|
59
|
+
});
|