@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
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>@pepys/source-telemetry</h1>
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
<p>
|
|
5
|
+
<a href="https://www.npmjs.com/package/@pepys/source-telemetry"><img src="https://img.shields.io/npm/v/@pepys/source-telemetry" alt="npm version"></a>
|
|
6
|
+
<a href="../../LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p>
|
|
10
|
+
<a href="../../docs/README.md">Documentation</a> •
|
|
11
|
+
<a href="../../docs/design/v1-plug-ins.md">The plug-ins of v1</a>
|
|
12
|
+
</p>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
The pepys telemetry plug-in, for Bun.
|
|
16
|
+
|
|
17
|
+
The plug-in receives the OTLP log exports of coding agents as facts, and builds one row per model
|
|
18
|
+
request and one per reported turn cost with its two consumers.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
bun add @pepys/source-telemetry
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- `telemetryPlugin`: the plug-in, to add to a deployment.
|
|
29
|
+
- `telemetryModelRequestConsumer` and `telemetryTurnCostConsumer`: the consumers of the log record
|
|
30
|
+
facts, with their SQL schemas.
|
|
31
|
+
|
|
32
|
+
## Documentation
|
|
33
|
+
|
|
34
|
+
The [pepys docs](../../docs/README.md) hold the design of the plug-ins.
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pepys/source-telemetry",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "The pepys telemetry plug-in: the OTLP log records of coding agents, with their model requests and turn costs",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/zgeoff/pepys.git",
|
|
9
|
+
"directory": "packages/source-telemetry"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"!src/**/*.test.ts",
|
|
14
|
+
"!src/**/test-utils"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"exports": "./src/index.ts",
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@pepys/kit": "0.0.1",
|
|
23
|
+
"@zgeoff/std": "0.4.3",
|
|
24
|
+
"tiny-invariant": "1.3.3",
|
|
25
|
+
"zod": "4.6.7"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@pepys/cli": "0.0.1",
|
|
29
|
+
"@pepys/ingest": "0.0.1",
|
|
30
|
+
"@pepys/testing": "0.0.1"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { findRecordInteger, findRecordNumber, findRecordText } from './find-record-number.ts';
|
|
2
|
+
import type { ModelRequestFields, StoredRecord } from './types.ts';
|
|
3
|
+
|
|
4
|
+
// One `api_request` record is one model request, with the cost Claude Code reports for it. A record
|
|
5
|
+
// with no model or no token count makes no row: no figure is invented.
|
|
6
|
+
export function findClaudeCodeModelRequest(
|
|
7
|
+
event: string,
|
|
8
|
+
record: StoredRecord,
|
|
9
|
+
): ModelRequestFields | undefined {
|
|
10
|
+
if (event !== 'api_request') {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const model = findRecordText(record, 'model');
|
|
15
|
+
const inputTokens = findRecordInteger(record, 'input_tokens');
|
|
16
|
+
const cacheReadTokens = findRecordInteger(record, 'cache_read_tokens');
|
|
17
|
+
const cacheCreationTokens = findRecordInteger(record, 'cache_creation_tokens');
|
|
18
|
+
const outputTokens = findRecordInteger(record, 'output_tokens');
|
|
19
|
+
|
|
20
|
+
if (
|
|
21
|
+
model === undefined ||
|
|
22
|
+
inputTokens === undefined ||
|
|
23
|
+
cacheReadTokens === undefined ||
|
|
24
|
+
cacheCreationTokens === undefined ||
|
|
25
|
+
outputTokens === undefined
|
|
26
|
+
) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
agent_session: findRecordText(record, 'session.id') ?? null,
|
|
32
|
+
model,
|
|
33
|
+
input_tokens: inputTokens,
|
|
34
|
+
cache_read_tokens: cacheReadTokens,
|
|
35
|
+
cache_creation_tokens: cacheCreationTokens,
|
|
36
|
+
output_tokens: outputTokens,
|
|
37
|
+
reported_cost_usd: findRecordNumber(record, 'cost_usd') ?? null,
|
|
38
|
+
duration_ms: findRecordInteger(record, 'duration_ms') ?? null,
|
|
39
|
+
agent: null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { findRecordInteger, findRecordText } from './find-record-number.ts';
|
|
2
|
+
import type { ModelRequestFields, StoredRecord } from './types.ts';
|
|
3
|
+
|
|
4
|
+
// One `sse_event` record of the kind `response.completed` is one model request. Codex reports no
|
|
5
|
+
// cost for one request, and counts the cached tokens inside its input count.
|
|
6
|
+
export function findCodexModelRequest(
|
|
7
|
+
event: string,
|
|
8
|
+
record: StoredRecord,
|
|
9
|
+
): ModelRequestFields | undefined {
|
|
10
|
+
if (event !== 'sse_event' || findRecordText(record, 'event.kind') !== 'response.completed') {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const model = findRecordText(record, 'model');
|
|
15
|
+
const inputTokens = findRecordInteger(record, 'input_token_count');
|
|
16
|
+
const outputTokens = findRecordInteger(record, 'output_token_count');
|
|
17
|
+
const cachedTokens = findRecordInteger(record, 'cached_token_count') ?? 0;
|
|
18
|
+
|
|
19
|
+
if (model === undefined || inputTokens === undefined || outputTokens === undefined) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
agent_session: findRecordText(record, 'conversation.id') ?? null,
|
|
25
|
+
model,
|
|
26
|
+
input_tokens: Math.max(inputTokens - cachedTokens, 0),
|
|
27
|
+
cache_read_tokens: cachedTokens,
|
|
28
|
+
cache_creation_tokens: 0,
|
|
29
|
+
output_tokens: outputTokens,
|
|
30
|
+
reported_cost_usd: null,
|
|
31
|
+
duration_ms: findRecordInteger(record, 'duration_ms') ?? null,
|
|
32
|
+
agent: null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { findRecordNumber, findRecordText } from './find-record-number.ts';
|
|
2
|
+
import type { StoredRecord, TurnCostFields } from './types.ts';
|
|
3
|
+
|
|
4
|
+
// One `turn_cost` record is Codex's own estimate for one turn. A record with no number makes no row.
|
|
5
|
+
export function findCodexTurnCost(event: string, record: StoredRecord): TurnCostFields | undefined {
|
|
6
|
+
if (event !== 'turn_cost') {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const cost = findRecordNumber(record, 'usage.estimated_usd');
|
|
11
|
+
|
|
12
|
+
if (cost === undefined) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
agent_session: findRecordText(record, 'conversation.id') ?? null,
|
|
18
|
+
model: findRecordText(record, 'model') ?? null,
|
|
19
|
+
cost_usd: cost,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { KeyValue, StoredRecord } from './types.ts';
|
|
2
|
+
|
|
3
|
+
// Whether an exporter writes a count as a number or as decimal text is not checked, so both count.
|
|
4
|
+
export function findRecordNumber(record: StoredRecord, name: string): number | undefined {
|
|
5
|
+
const value = findRecordAttribute(record, name);
|
|
6
|
+
|
|
7
|
+
if (typeof value === 'number') {
|
|
8
|
+
return Number.isFinite(value) ? value : undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (typeof value !== 'string' || !isDecimalText(value)) {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const number = Number(value);
|
|
16
|
+
|
|
17
|
+
return Number.isFinite(number) ? number : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function findRecordInteger(record: StoredRecord, name: string): number | undefined {
|
|
21
|
+
const number = findRecordNumber(record, name);
|
|
22
|
+
|
|
23
|
+
return number !== undefined && Number.isSafeInteger(number) ? number : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// non-empty text; a number becomes its decimal text
|
|
27
|
+
export function findRecordText(record: StoredRecord, name: string): string | undefined {
|
|
28
|
+
const value = findRecordAttribute(record, name);
|
|
29
|
+
|
|
30
|
+
if (typeof value === 'number') {
|
|
31
|
+
return String(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Scalar = string | number | boolean;
|
|
38
|
+
|
|
39
|
+
// The record's own attribute first, then the resource's. An OTLP value is a one-key object, such as
|
|
40
|
+
// `{ "intValue": "12" }`, and a list or a map value counts as absent.
|
|
41
|
+
function findRecordAttribute(record: StoredRecord, name: string): Scalar | undefined {
|
|
42
|
+
const own = findValue(record.logRecord.attributes ?? [], name);
|
|
43
|
+
|
|
44
|
+
return own ?? findValue(record.resource.attributes, name);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function findValue(attributes: readonly KeyValue[], name: string): Scalar | undefined {
|
|
48
|
+
for (const attribute of attributes) {
|
|
49
|
+
if (attribute.key === name) {
|
|
50
|
+
return toScalar(attribute.value);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function toScalar(value: unknown): Scalar | undefined {
|
|
58
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const text = 'stringValue' in value ? value.stringValue : undefined;
|
|
63
|
+
const integer = 'intValue' in value ? value.intValue : undefined;
|
|
64
|
+
const double = 'doubleValue' in value ? value.doubleValue : undefined;
|
|
65
|
+
const flag = 'boolValue' in value ? value.boolValue : undefined;
|
|
66
|
+
|
|
67
|
+
if (typeof text === 'string') {
|
|
68
|
+
return text;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (typeof integer === 'number' || typeof integer === 'string') {
|
|
72
|
+
return integer;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof double === 'number' || typeof double === 'string') {
|
|
76
|
+
return double;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return typeof flag === 'boolean' ? flag : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Digits with one optional leading minus and one optional point, such as `-12` or `0.4`. `Number`
|
|
83
|
+
// alone would also take an empty text, hex and `Infinity`.
|
|
84
|
+
function isDecimalText(text: string): boolean {
|
|
85
|
+
let digits = 0;
|
|
86
|
+
let points = 0;
|
|
87
|
+
|
|
88
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
89
|
+
const character = text.charAt(index);
|
|
90
|
+
|
|
91
|
+
if (character >= '0' && character <= '9') {
|
|
92
|
+
digits += 1;
|
|
93
|
+
} else if (character === '.') {
|
|
94
|
+
points += 1;
|
|
95
|
+
} else if (character !== '-' || index !== 0) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return digits > 0 && points <= 1;
|
|
101
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ProductName } from './types.ts';
|
|
2
|
+
|
|
3
|
+
const productNames: readonly ProductName[] = ['claude_code', 'codex'];
|
|
4
|
+
|
|
5
|
+
// `telemetry.<product>.<event>`, with `unlisted` as the event of a record the product does not list
|
|
6
|
+
export function parseFactType(type: string): { product: ProductName; event: string } | undefined {
|
|
7
|
+
const parts = type.split('.');
|
|
8
|
+
const [source, productPart, event] = parts;
|
|
9
|
+
const product = productNames.find((name) => name === productPart);
|
|
10
|
+
|
|
11
|
+
if (
|
|
12
|
+
parts.length !== 3 ||
|
|
13
|
+
source !== 'telemetry' ||
|
|
14
|
+
product === undefined ||
|
|
15
|
+
event === undefined
|
|
16
|
+
) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return { product, event };
|
|
21
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The consumers import no ingest code, so they keep their own list of product names. A test holds
|
|
2
|
+
// it equal to the product list of the source.
|
|
3
|
+
export type ProductName = 'claude_code' | 'codex';
|
|
4
|
+
|
|
5
|
+
export interface KeyValue {
|
|
6
|
+
key: string;
|
|
7
|
+
value: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// The part of a stored log record that a product's reader looks at.
|
|
11
|
+
export interface StoredRecord {
|
|
12
|
+
resource: { attributes: readonly KeyValue[] };
|
|
13
|
+
logRecord: { attributes?: readonly KeyValue[] | undefined };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ModelRequestFields {
|
|
17
|
+
agent_session: string | null;
|
|
18
|
+
model: string;
|
|
19
|
+
input_tokens: number;
|
|
20
|
+
cache_read_tokens: number;
|
|
21
|
+
cache_creation_tokens: number;
|
|
22
|
+
output_tokens: number;
|
|
23
|
+
|
|
24
|
+
// null when the product reports no cost for one request
|
|
25
|
+
reported_cost_usd: number | null;
|
|
26
|
+
duration_ms: number | null;
|
|
27
|
+
agent: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TurnCostFields {
|
|
31
|
+
agent_session: string | null;
|
|
32
|
+
model: string | null;
|
|
33
|
+
cost_usd: number;
|
|
34
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import {
|
|
2
|
+
initTelemetryModelRequestModel,
|
|
3
|
+
parseTelemetryModelRequestFact,
|
|
4
|
+
parseTelemetrySessionID,
|
|
5
|
+
toTelemetryModelRequestID,
|
|
6
|
+
updateTelemetryModelRequestModel,
|
|
7
|
+
} from '../generated/telemetry-model-request-boundary.ts';
|
|
8
|
+
import type {
|
|
9
|
+
TelemetryModelRequestMessage,
|
|
10
|
+
TelemetryModelRequestModel,
|
|
11
|
+
TelemetryModelRequestOperation,
|
|
12
|
+
} from '../generated/telemetry-model-request-boundary.ts';
|
|
13
|
+
import { findClaudeCodeModelRequest } from './products/find-claude-code-model-request.ts';
|
|
14
|
+
import { findCodexModelRequest } from './products/find-codex-model-request.ts';
|
|
15
|
+
import { parseFactType } from './products/parse-fact-type.ts';
|
|
16
|
+
import type { ModelRequestFields, ProductName, StoredRecord } from './products/types.ts';
|
|
17
|
+
|
|
18
|
+
export type {
|
|
19
|
+
TelemetryModelRequestMessage,
|
|
20
|
+
TelemetryModelRequestModel,
|
|
21
|
+
} from '../generated/telemetry-model-request-boundary.ts';
|
|
22
|
+
|
|
23
|
+
// scriptc takes the model and message types through a re-export, but not the two functions
|
|
24
|
+
export function init(): TelemetryModelRequestModel {
|
|
25
|
+
return initTelemetryModelRequestModel();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function update(
|
|
29
|
+
model: TelemetryModelRequestModel,
|
|
30
|
+
message: TelemetryModelRequestMessage,
|
|
31
|
+
): TelemetryModelRequestModel {
|
|
32
|
+
return updateTelemetryModelRequestModel(model, message);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type ModelRequestReader = (event: string, record: StoredRecord) => ModelRequestFields | undefined;
|
|
36
|
+
|
|
37
|
+
// One reader per product: a product with no entry here fails the type check.
|
|
38
|
+
const readers: Readonly<Record<ProductName, ModelRequestReader>> = {
|
|
39
|
+
claude_code: findClaudeCodeModelRequest,
|
|
40
|
+
codex: findCodexModelRequest,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const factIDPrefix = 'event:telemetry:';
|
|
44
|
+
|
|
45
|
+
export function applyFact(factJSON: string): string {
|
|
46
|
+
return JSON.stringify(mapFact(factJSON));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The host calls it once per fact ID, so a copy never gets here, and the row's key comes from the
|
|
50
|
+
// fact's ID, so the order of the facts changes nothing.
|
|
51
|
+
function mapFact(factJSON: string): TelemetryModelRequestOperation[] {
|
|
52
|
+
const fact = parseTelemetryModelRequestFact(factJSON);
|
|
53
|
+
const type = fact === undefined ? undefined : parseFactType(fact.type);
|
|
54
|
+
|
|
55
|
+
if (fact === undefined || type === undefined || !fact.id.startsWith(factIDPrefix)) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ingest chose the session, so the row copies the fact's entity and reads no attribute for it
|
|
60
|
+
const session = parseTelemetrySessionID(fact.entity);
|
|
61
|
+
const fields = readers[type.product](type.event, fact.payload);
|
|
62
|
+
|
|
63
|
+
if (session === undefined || fields === undefined) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return [
|
|
68
|
+
{
|
|
69
|
+
upsert: {
|
|
70
|
+
telemetry_model_request: {
|
|
71
|
+
id: toTelemetryModelRequestID(fact.id.slice(factIDPrefix.length)),
|
|
72
|
+
session_id: session,
|
|
73
|
+
agent_session: fields.agent_session,
|
|
74
|
+
product: type.product,
|
|
75
|
+
model: fields.model,
|
|
76
|
+
input_tokens: fields.input_tokens,
|
|
77
|
+
cache_read_tokens: fields.cache_read_tokens,
|
|
78
|
+
cache_creation_tokens: fields.cache_creation_tokens,
|
|
79
|
+
output_tokens: fields.output_tokens,
|
|
80
|
+
cost_usd: fields.reported_cost_usd,
|
|
81
|
+
cost_basis: fields.reported_cost_usd === null ? 'none' : 'reported',
|
|
82
|
+
duration_ms: fields.duration_ms,
|
|
83
|
+
agent: fields.agent,
|
|
84
|
+
origin_issuer: fact.payload.origin.issuer,
|
|
85
|
+
origin_subject: fact.payload.origin.subject,
|
|
86
|
+
at: fact.at,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
initTelemetryTurnCostModel,
|
|
3
|
+
parseTelemetrySessionID,
|
|
4
|
+
parseTelemetryTurnCostFact,
|
|
5
|
+
toTelemetryTurnCostID,
|
|
6
|
+
updateTelemetryTurnCostModel,
|
|
7
|
+
} from '../generated/telemetry-turn-cost-boundary.ts';
|
|
8
|
+
import type {
|
|
9
|
+
TelemetryTurnCostMessage,
|
|
10
|
+
TelemetryTurnCostModel,
|
|
11
|
+
TelemetryTurnCostOperation,
|
|
12
|
+
} from '../generated/telemetry-turn-cost-boundary.ts';
|
|
13
|
+
import { findCodexTurnCost } from './products/find-codex-turn-cost.ts';
|
|
14
|
+
import { parseFactType } from './products/parse-fact-type.ts';
|
|
15
|
+
import type { ProductName, StoredRecord, TurnCostFields } from './products/types.ts';
|
|
16
|
+
|
|
17
|
+
export type {
|
|
18
|
+
TelemetryTurnCostMessage,
|
|
19
|
+
TelemetryTurnCostModel,
|
|
20
|
+
} from '../generated/telemetry-turn-cost-boundary.ts';
|
|
21
|
+
|
|
22
|
+
// scriptc takes the model and message types through a re-export, but not the two functions
|
|
23
|
+
export function init(): TelemetryTurnCostModel {
|
|
24
|
+
return initTelemetryTurnCostModel();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function update(
|
|
28
|
+
model: TelemetryTurnCostModel,
|
|
29
|
+
message: TelemetryTurnCostMessage,
|
|
30
|
+
): TelemetryTurnCostModel {
|
|
31
|
+
return updateTelemetryTurnCostModel(model, message);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type TurnCostReader = (event: string, record: StoredRecord) => TurnCostFields | undefined;
|
|
35
|
+
|
|
36
|
+
// One reader per product: a product with no entry here fails the type check. Claude Code reports
|
|
37
|
+
// its cost per request, so it has no turn cost, and a sum over both tables counts nothing twice.
|
|
38
|
+
const readers: Readonly<Record<ProductName, TurnCostReader>> = {
|
|
39
|
+
claude_code: findNoTurnCost,
|
|
40
|
+
codex: findCodexTurnCost,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const factIDPrefix = 'event:telemetry:';
|
|
44
|
+
|
|
45
|
+
export function applyFact(factJSON: string): string {
|
|
46
|
+
return JSON.stringify(mapFact(factJSON));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function mapFact(factJSON: string): TelemetryTurnCostOperation[] {
|
|
50
|
+
const fact = parseTelemetryTurnCostFact(factJSON);
|
|
51
|
+
const type = fact === undefined ? undefined : parseFactType(fact.type);
|
|
52
|
+
|
|
53
|
+
if (fact === undefined || type === undefined || !fact.id.startsWith(factIDPrefix)) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const session = parseTelemetrySessionID(fact.entity);
|
|
58
|
+
const fields = readers[type.product](type.event, fact.payload);
|
|
59
|
+
|
|
60
|
+
if (session === undefined || fields === undefined) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return [
|
|
65
|
+
{
|
|
66
|
+
upsert: {
|
|
67
|
+
telemetry_turn_cost: {
|
|
68
|
+
id: toTelemetryTurnCostID(fact.id.slice(factIDPrefix.length)),
|
|
69
|
+
session_id: session,
|
|
70
|
+
agent_session: fields.agent_session,
|
|
71
|
+
product: type.product,
|
|
72
|
+
model: fields.model,
|
|
73
|
+
cost_usd: fields.cost_usd,
|
|
74
|
+
origin_issuer: fact.payload.origin.issuer,
|
|
75
|
+
origin_subject: fact.payload.origin.subject,
|
|
76
|
+
at: fact.at,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function findNoTurnCost(): TurnCostFields | undefined {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|