@pepys/source-telemetry 0.0.0-stage → 0.1.0

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.
Files changed (32) hide show
  1. package/README.md +35 -2
  2. package/package.json +30 -4
  3. package/src/consumers/products/find-claude-code-model-request.ts +41 -0
  4. package/src/consumers/products/find-codex-model-request.ts +34 -0
  5. package/src/consumers/products/find-codex-turn-cost.ts +21 -0
  6. package/src/consumers/products/find-record-number.ts +101 -0
  7. package/src/consumers/products/parse-fact-type.ts +21 -0
  8. package/src/consumers/products/types.ts +34 -0
  9. package/src/consumers/telemetry-model-request.ts +91 -0
  10. package/src/consumers/telemetry-turn-cost.ts +85 -0
  11. package/src/generated/telemetry-model-request-boundary.ts +368 -0
  12. package/src/generated/telemetry-model-request.sql +35 -0
  13. package/src/generated/telemetry-turn-cost-boundary.ts +361 -0
  14. package/src/generated/telemetry-turn-cost.sql +26 -0
  15. package/src/index.ts +26 -0
  16. package/src/log-record-family.ts +41 -0
  17. package/src/otlp/format-canonical-json.ts +24 -0
  18. package/src/otlp/parse-logs-export.ts +71 -0
  19. package/src/otlp/read-otlp-facts.ts +226 -0
  20. package/src/otlp/receive-otlp-logs.ts +130 -0
  21. package/src/otlp/to-attribute-map.ts +63 -0
  22. package/src/otlp/types.ts +36 -0
  23. package/src/products/claude-code/claude-code-product.ts +45 -0
  24. package/src/products/codex/codex-product.ts +37 -0
  25. package/src/products/define-telemetry-product.ts +7 -0
  26. package/src/products/find-attribute-text.ts +12 -0
  27. package/src/products/products.ts +8 -0
  28. package/src/products/types.ts +21 -0
  29. package/src/queries/session-model-requests-query.ts +55 -0
  30. package/src/telemetry-model-request-consumer.ts +59 -0
  31. package/src/telemetry-plugin.ts +44 -0
  32. 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,7 @@
1
+ import type { TelemetryProduct } from './types.ts';
2
+
3
+ export function defineTelemetryProduct<const Name extends string, const Event extends string>(
4
+ product: TelemetryProduct<Name, Event>,
5
+ ): TelemetryProduct<Name, Event> {
6
+ return product;
7
+ }
@@ -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,55 @@
1
+ import { defineQuery } from '@pepys/kit';
2
+ import type { EntityID } from '@pepys/kit';
3
+ import { toTagged } from '@zgeoff/std';
4
+ import { z } from 'zod';
5
+ import { telemetryModelRequestConsumer } from '../telemetry-model-request-consumer.ts';
6
+
7
+ const modelRequestSchema = z.object({
8
+ id: z.string(),
9
+ product: z.string(),
10
+ model: z.string(),
11
+ input_tokens: z.number(),
12
+ cache_read_tokens: z.number(),
13
+ cache_creation_tokens: z.number(),
14
+ output_tokens: z.number(),
15
+ cost_usd: z.number().nullable(),
16
+ at: z.string(),
17
+ });
18
+
19
+ // The sender writes the session of a record, so a row counts only when the caller names the sender
20
+ // that owns the session, as another plug-in's fact gives it.
21
+ export const sessionModelRequestsQuery = defineQuery({
22
+ name: 'telemetry_session_model_requests',
23
+ description:
24
+ 'Returns the model requests of one telemetry session, oldest first, that the named sender stored: the sender is the issuer and the subject of the identity that proved each export. A row from any other sender is left out.',
25
+ input: z.object({
26
+ session: z.string().min(1),
27
+ issuer: z.string().min(1),
28
+ subject: z.string().min(1),
29
+ }),
30
+ output: z.array(modelRequestSchema),
31
+ reads: [telemetryModelRequestConsumer],
32
+ handler: async (call) => {
33
+ const rows = await call.db
34
+ .selectFrom('telemetry_model_request')
35
+ .select([
36
+ 'id',
37
+ 'product',
38
+ 'model',
39
+ 'input_tokens',
40
+ 'cache_read_tokens',
41
+ 'cache_creation_tokens',
42
+ 'output_tokens',
43
+ 'cost_usd',
44
+ 'at',
45
+ ])
46
+ .where('session_id', '=', toTagged<EntityID>(call.input.session))
47
+ .where('origin_issuer', '=', call.input.issuer)
48
+ .where('origin_subject', '=', call.input.subject)
49
+ .orderBy('at')
50
+ .orderBy('id')
51
+ .execute();
52
+
53
+ return rows;
54
+ },
55
+ });