@unpolarize/code-sessions 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.
- package/bin/code-sessions.mjs +20 -0
- package/dist/chunk-ZJG2DWAK.js +2321 -0
- package/dist/cli.js +308 -0
- package/dist/index.js +162 -0
- package/package.json +21 -0
- package/src/adapters/adapters.test.ts +121 -0
- package/src/adapters/codex.ts +228 -0
- package/src/adapters/grok.ts +179 -0
- package/src/adapters/import.ts +79 -0
- package/src/adapters/index.ts +3 -0
- package/src/analytics/analytics.test.ts +94 -0
- package/src/analytics/command.ts +38 -0
- package/src/analytics/digest.ts +48 -0
- package/src/analytics/rollup.ts +114 -0
- package/src/analytics/site.ts +41 -0
- package/src/capture.test.ts +103 -0
- package/src/capture.ts +121 -0
- package/src/cli.ts +118 -0
- package/src/cliargs.test.ts +31 -0
- package/src/cliargs.ts +77 -0
- package/src/commands.test.ts +99 -0
- package/src/commands.ts +266 -0
- package/src/config.test.ts +36 -0
- package/src/config.ts +158 -0
- package/src/daemon.test.ts +130 -0
- package/src/daemon.ts +216 -0
- package/src/hooks/install.test.ts +47 -0
- package/src/hooks/install.ts +81 -0
- package/src/hooks/shim.test.ts +57 -0
- package/src/hooks/shim.ts +26 -0
- package/src/hygiene.test.ts +78 -0
- package/src/hygiene.ts +107 -0
- package/src/index.ts +21 -0
- package/src/index_store/db.test.ts +108 -0
- package/src/index_store/db.ts +289 -0
- package/src/index_store/index.ts +2 -0
- package/src/index_store/sync.test.ts +88 -0
- package/src/index_store/sync.ts +83 -0
- package/src/insights/heuristics.test.ts +71 -0
- package/src/insights/heuristics.ts +106 -0
- package/src/insights/index.ts +4 -0
- package/src/insights/labeler.test.ts +105 -0
- package/src/insights/labeler.ts +136 -0
- package/src/insights/llm.test.ts +77 -0
- package/src/insights/llm.ts +130 -0
- package/src/insights/provider.ts +37 -0
- package/src/ipc.test.ts +35 -0
- package/src/ipc.ts +70 -0
- package/src/pricing.test.ts +28 -0
- package/src/pricing.ts +45 -0
- package/src/state.test.ts +46 -0
- package/src/state.ts +89 -0
- package/src/store/git.test.ts +99 -0
- package/src/store/git.ts +138 -0
- package/src/store/paths.ts +45 -0
- package/src/store/scan.ts +39 -0
- package/src/store/writer.test.ts +93 -0
- package/src/store/writer.ts +135 -0
- package/src/tail.test.ts +50 -0
- package/src/tail.ts +47 -0
- package/src/telemetry/exporter.test.ts +104 -0
- package/src/telemetry/exporter.ts +64 -0
- package/src/telemetry/index.ts +2 -0
- package/src/telemetry/otlp.test.ts +123 -0
- package/src/telemetry/otlp.ts +215 -0
- package/src/test/e2e.test.ts +112 -0
- package/src/test/tmp.ts +36 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { parseSession, type Turn } from '@unpolarize/code-sessions-schema';
|
|
4
|
+
import { withTempDir } from '../test/tmp';
|
|
5
|
+
import { envelopeFile, sessionDir } from './paths';
|
|
6
|
+
import { computeEnvelope, readTurns, rebuildEnvelope, writeTurnFile } from './writer';
|
|
7
|
+
|
|
8
|
+
function mkTurn(i: number, over: Partial<Turn> = {}): Turn {
|
|
9
|
+
return {
|
|
10
|
+
schema: 'session-store/turn@1',
|
|
11
|
+
session_id: 'sess-1',
|
|
12
|
+
host: 'test-host',
|
|
13
|
+
agent: 'claude-code',
|
|
14
|
+
turn_index: i,
|
|
15
|
+
ts: `2026-06-20T08:0${i}:00Z`,
|
|
16
|
+
role: i % 2 === 0 ? 'user' : 'assistant',
|
|
17
|
+
text: `turn ${i}`,
|
|
18
|
+
tool_calls: [],
|
|
19
|
+
usage: { input_tokens: 100, output_tokens: 10, cache_read_tokens: 0, cache_write_tokens: 0 },
|
|
20
|
+
scrubbed: false,
|
|
21
|
+
raw_ref: null,
|
|
22
|
+
...over,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('writeTurnFile', () => {
|
|
27
|
+
it('writes immutably and never overwrites', () => {
|
|
28
|
+
withTempDir((store) => {
|
|
29
|
+
const dir = sessionDir(store, 'test-host', '2026-06', 'sess-1');
|
|
30
|
+
const first = writeTurnFile(dir, mkTurn(0, { text: 'original' }));
|
|
31
|
+
expect(first.written).toBe(true);
|
|
32
|
+
const second = writeTurnFile(dir, mkTurn(0, { text: 'CLOBBER' }));
|
|
33
|
+
expect(second.written).toBe(false);
|
|
34
|
+
const onDisk = JSON.parse(readFileSync(first.path, 'utf8')) as Turn;
|
|
35
|
+
expect(onDisk.text).toBe('original');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('computeEnvelope', () => {
|
|
41
|
+
it('aggregates totals, counts, and timestamps from turns', () => {
|
|
42
|
+
const turns = [
|
|
43
|
+
mkTurn(0),
|
|
44
|
+
mkTurn(1, {
|
|
45
|
+
tool_calls: [{ name: 'Edit' }],
|
|
46
|
+
telemetry: { cost_usd: 0.5 },
|
|
47
|
+
}),
|
|
48
|
+
];
|
|
49
|
+
const env = computeEnvelope(
|
|
50
|
+
turns,
|
|
51
|
+
{ model: 'claude-opus-4-8', project_path: '/p', git_branch: 'main', title: 'T' },
|
|
52
|
+
{ session_id: 'sess-1', host: 'test-host', agent: 'claude-code', native_uuid: 'sess-1' },
|
|
53
|
+
);
|
|
54
|
+
expect(env.turn_count).toBe(2);
|
|
55
|
+
expect(env.tool_call_count).toBe(1);
|
|
56
|
+
expect(env.totals.input_tokens).toBe(200);
|
|
57
|
+
expect(env.totals.cost_usd).toBe(0.5);
|
|
58
|
+
expect(env.started_at).toBe('2026-06-20T08:00:00Z');
|
|
59
|
+
expect(env.ended_at).toBe('2026-06-20T08:01:00Z');
|
|
60
|
+
expect(env.model).toBe('claude-opus-4-8');
|
|
61
|
+
expect(env.title).toBe('T');
|
|
62
|
+
expect(() => parseSession(env)).not.toThrow();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('preserves prior labels from an existing envelope', () => {
|
|
66
|
+
const env = computeEnvelope([mkTurn(0)], {}, {
|
|
67
|
+
session_id: 'sess-1',
|
|
68
|
+
host: 'h',
|
|
69
|
+
agent: 'claude-code',
|
|
70
|
+
native_uuid: 'sess-1',
|
|
71
|
+
}, { labels: ['debugging'] });
|
|
72
|
+
expect(env.labels).toEqual(['debugging']);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('rebuildEnvelope', () => {
|
|
77
|
+
it('reads turns from disk and writes session.json', () => {
|
|
78
|
+
withTempDir((store) => {
|
|
79
|
+
const dir = sessionDir(store, 'test-host', '2026-06', 'sess-1');
|
|
80
|
+
writeTurnFile(dir, mkTurn(0));
|
|
81
|
+
writeTurnFile(dir, mkTurn(1));
|
|
82
|
+
const env = rebuildEnvelope(store, 'test-host', '2026-06', 'sess-1', { model: 'claude-opus-4-8' }, {
|
|
83
|
+
session_id: 'sess-1',
|
|
84
|
+
host: 'test-host',
|
|
85
|
+
agent: 'claude-code',
|
|
86
|
+
native_uuid: 'sess-1',
|
|
87
|
+
});
|
|
88
|
+
expect(env.turn_count).toBe(2);
|
|
89
|
+
expect(existsSync(envelopeFile(dir))).toBe(true);
|
|
90
|
+
expect(readTurns(dir)).toHaveLength(2);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
SCHEMA_VERSIONS,
|
|
5
|
+
type AgentKind,
|
|
6
|
+
type SessionEnvelope,
|
|
7
|
+
type Turn,
|
|
8
|
+
} from '@unpolarize/code-sessions-schema';
|
|
9
|
+
import type { ClaudeSessionMeta } from '@unpolarize/code-sessions-schema';
|
|
10
|
+
import { envelopeFile, rawBlobFile, sessionDir, turnFile } from './paths';
|
|
11
|
+
|
|
12
|
+
function ensureDir(filePath: string): void {
|
|
13
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function writeJsonAtomic(filePath: string, value: unknown): void {
|
|
17
|
+
ensureDir(filePath);
|
|
18
|
+
const tmp = `${filePath}.tmp`;
|
|
19
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
|
|
20
|
+
renameSync(tmp, filePath); // atomic on the same filesystem
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface WriteTurnResult {
|
|
24
|
+
path: string;
|
|
25
|
+
written: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Write an immutable per-turn file. Never overwrites an existing turn. */
|
|
29
|
+
export function writeTurnFile(dir: string, turn: Turn): WriteTurnResult {
|
|
30
|
+
const path = turnFile(dir, turn.turn_index);
|
|
31
|
+
if (existsSync(path)) return { path, written: false };
|
|
32
|
+
ensureDir(path);
|
|
33
|
+
writeFileSync(path, `${JSON.stringify(turn, null, 2)}\n`);
|
|
34
|
+
return { path, written: true };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Write a content-addressed blob (idempotent). */
|
|
38
|
+
export function writeBlobFile(dir: string, sha: string, content: string): string {
|
|
39
|
+
const path = rawBlobFile(dir, sha);
|
|
40
|
+
if (!existsSync(path)) {
|
|
41
|
+
ensureDir(path);
|
|
42
|
+
writeFileSync(path, content);
|
|
43
|
+
}
|
|
44
|
+
return path;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readTurns(dir: string): Turn[] {
|
|
48
|
+
const turnsDir = `${dir}/turns`;
|
|
49
|
+
if (!existsSync(turnsDir)) return [];
|
|
50
|
+
return readdirSync(turnsDir)
|
|
51
|
+
.filter((f) => f.endsWith('.json') && !f.endsWith('.tmp'))
|
|
52
|
+
.sort()
|
|
53
|
+
.map((f) => JSON.parse(readFileSync(`${turnsDir}/${f}`, 'utf8')) as Turn);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface EnvelopeIdentity {
|
|
57
|
+
session_id: string;
|
|
58
|
+
host: string;
|
|
59
|
+
agent: AgentKind;
|
|
60
|
+
native_uuid: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Pure: derive a session envelope from its turns + extracted metadata. */
|
|
64
|
+
export function computeEnvelope(
|
|
65
|
+
turns: Turn[],
|
|
66
|
+
meta: ClaudeSessionMeta,
|
|
67
|
+
identity: EnvelopeIdentity,
|
|
68
|
+
existing?: Partial<SessionEnvelope>,
|
|
69
|
+
): SessionEnvelope {
|
|
70
|
+
let inputTokens = 0;
|
|
71
|
+
let outputTokens = 0;
|
|
72
|
+
let cost = 0;
|
|
73
|
+
let toolCalls = 0;
|
|
74
|
+
for (const t of turns) {
|
|
75
|
+
inputTokens += t.usage.input_tokens;
|
|
76
|
+
outputTokens += t.usage.output_tokens;
|
|
77
|
+
toolCalls += t.tool_calls.length;
|
|
78
|
+
cost += t.telemetry?.cost_usd ?? 0;
|
|
79
|
+
}
|
|
80
|
+
const first = turns[0];
|
|
81
|
+
const last = turns[turns.length - 1];
|
|
82
|
+
|
|
83
|
+
const env: SessionEnvelope = {
|
|
84
|
+
schema: SCHEMA_VERSIONS.session,
|
|
85
|
+
session_id: identity.session_id,
|
|
86
|
+
host: identity.host,
|
|
87
|
+
agent: identity.agent,
|
|
88
|
+
project_path: meta.project_path ?? existing?.project_path ?? '',
|
|
89
|
+
turn_count: turns.length,
|
|
90
|
+
tool_call_count: toolCalls,
|
|
91
|
+
totals: {
|
|
92
|
+
input_tokens: inputTokens,
|
|
93
|
+
output_tokens: outputTokens,
|
|
94
|
+
cost_usd: Math.round(cost * 1e6) / 1e6,
|
|
95
|
+
},
|
|
96
|
+
labels: existing?.labels ?? [],
|
|
97
|
+
native_ref: { format: 'claude-jsonl', uuid: identity.native_uuid },
|
|
98
|
+
};
|
|
99
|
+
const branch = meta.git_branch ?? existing?.git_branch;
|
|
100
|
+
if (branch) env.git_branch = branch;
|
|
101
|
+
const model = meta.model ?? existing?.model;
|
|
102
|
+
if (model) env.model = model;
|
|
103
|
+
const startedAt = meta.started_at ?? first?.ts ?? existing?.started_at;
|
|
104
|
+
if (startedAt) env.started_at = startedAt;
|
|
105
|
+
const endedAt = meta.ended_at ?? last?.ts ?? existing?.ended_at;
|
|
106
|
+
if (endedAt) env.ended_at = endedAt;
|
|
107
|
+
const title = meta.title ?? existing?.title;
|
|
108
|
+
if (title) env.title = title;
|
|
109
|
+
return env;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Read turns from disk, derive the envelope (preserving prior labels/title), and write session.json. */
|
|
113
|
+
export function rebuildEnvelope(
|
|
114
|
+
storeDir: string,
|
|
115
|
+
host: string,
|
|
116
|
+
month: string,
|
|
117
|
+
sessionId: string,
|
|
118
|
+
meta: ClaudeSessionMeta,
|
|
119
|
+
identity: EnvelopeIdentity,
|
|
120
|
+
): SessionEnvelope {
|
|
121
|
+
const dir = sessionDir(storeDir, host, month, sessionId);
|
|
122
|
+
const turns = readTurns(dir);
|
|
123
|
+
const envPath = envelopeFile(dir);
|
|
124
|
+
let existing: Partial<SessionEnvelope> | undefined;
|
|
125
|
+
if (existsSync(envPath)) {
|
|
126
|
+
try {
|
|
127
|
+
existing = JSON.parse(readFileSync(envPath, 'utf8')) as SessionEnvelope;
|
|
128
|
+
} catch {
|
|
129
|
+
/* ignore */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const env = computeEnvelope(turns, meta, identity, existing);
|
|
133
|
+
writeJsonAtomic(envPath, env);
|
|
134
|
+
return env;
|
|
135
|
+
}
|
package/src/tail.test.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { appendFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { readNewLines } from './tail';
|
|
5
|
+
import { withTempDir } from './test/tmp';
|
|
6
|
+
|
|
7
|
+
describe('readNewLines', () => {
|
|
8
|
+
it('returns empty for a missing file', () => {
|
|
9
|
+
const r = readNewLines('/no/such/file.jsonl', 0);
|
|
10
|
+
expect(r.records).toEqual([]);
|
|
11
|
+
expect(r.newOffset).toBe(0);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('reads complete lines and advances the offset', () => {
|
|
15
|
+
withTempDir((dir) => {
|
|
16
|
+
const f = join(dir, 's.jsonl');
|
|
17
|
+
writeFileSync(f, '{"a":1}\n{"b":2}\n');
|
|
18
|
+
const r = readNewLines(f, 0);
|
|
19
|
+
expect(r.records).toEqual([{ a: 1 }, { b: 2 }]);
|
|
20
|
+
expect(r.newOffset).toBe(16);
|
|
21
|
+
// re-read from new offset yields nothing
|
|
22
|
+
expect(readNewLines(f, r.newOffset).records).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('does not consume a trailing partial line', () => {
|
|
27
|
+
withTempDir((dir) => {
|
|
28
|
+
const f = join(dir, 's.jsonl');
|
|
29
|
+
writeFileSync(f, '{"a":1}\n{"b":2'); // second line incomplete
|
|
30
|
+
const r = readNewLines(f, 0);
|
|
31
|
+
expect(r.records).toEqual([{ a: 1 }]);
|
|
32
|
+
expect(r.newOffset).toBe(8);
|
|
33
|
+
// complete the line; next read picks it up
|
|
34
|
+
appendFileSync(f, '}\n');
|
|
35
|
+
const r2 = readNewLines(f, r.newOffset);
|
|
36
|
+
expect(r2.records).toEqual([{ b: 2 }]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('counts parse errors but still advances', () => {
|
|
41
|
+
withTempDir((dir) => {
|
|
42
|
+
const f = join(dir, 's.jsonl');
|
|
43
|
+
writeFileSync(f, 'not json\n{"ok":1}\n');
|
|
44
|
+
const r = readNewLines(f, 0);
|
|
45
|
+
expect(r.parseErrors).toBe(1);
|
|
46
|
+
expect(r.records).toEqual([{ ok: 1 }]);
|
|
47
|
+
expect(r.newOffset).toBe(18);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
});
|
package/src/tail.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
export interface TailResult {
|
|
4
|
+
/** parsed JSON objects for each complete new line */
|
|
5
|
+
records: unknown[];
|
|
6
|
+
/** raw text of each complete new line (parse failures still advance offset) */
|
|
7
|
+
rawLines: string[];
|
|
8
|
+
/** new byte offset to persist (only advances past complete, newline-terminated lines) */
|
|
9
|
+
newOffset: number;
|
|
10
|
+
/** number of lines that failed to JSON.parse */
|
|
11
|
+
parseErrors: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read newly-appended complete lines from a JSONL file starting at a byte
|
|
16
|
+
* offset. A trailing partial line (no terminating newline) is left unconsumed
|
|
17
|
+
* so the next read picks it up once it's complete.
|
|
18
|
+
*/
|
|
19
|
+
export function readNewLines(filePath: string, fromOffset: number): TailResult {
|
|
20
|
+
const empty: TailResult = { records: [], rawLines: [], newOffset: fromOffset, parseErrors: 0 };
|
|
21
|
+
if (!existsSync(filePath)) return empty;
|
|
22
|
+
|
|
23
|
+
const buf = readFileSync(filePath);
|
|
24
|
+
if (fromOffset >= buf.length) return { ...empty, newOffset: buf.length };
|
|
25
|
+
|
|
26
|
+
const slice = buf.subarray(fromOffset);
|
|
27
|
+
const lastNewline = slice.lastIndexOf(0x0a); // '\n'
|
|
28
|
+
if (lastNewline < 0) return empty; // no complete line yet
|
|
29
|
+
|
|
30
|
+
const complete = slice.subarray(0, lastNewline + 1).toString('utf8');
|
|
31
|
+
const consumedBytes = Buffer.byteLength(complete, 'utf8');
|
|
32
|
+
|
|
33
|
+
const records: unknown[] = [];
|
|
34
|
+
const rawLines: string[] = [];
|
|
35
|
+
let parseErrors = 0;
|
|
36
|
+
for (const line of complete.split('\n')) {
|
|
37
|
+
if (line.trim().length === 0) continue;
|
|
38
|
+
rawLines.push(line);
|
|
39
|
+
try {
|
|
40
|
+
records.push(JSON.parse(line));
|
|
41
|
+
} catch {
|
|
42
|
+
parseErrors++;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { records, rawLines, newOffset: fromOffset + consumedBytes, parseErrors };
|
|
47
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createServer, type Server } from 'node:http';
|
|
2
|
+
import type { AddressInfo } from 'node:net';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import type { Turn } from '@unpolarize/code-sessions-schema';
|
|
5
|
+
import { rebuildEnvelope, writeTurnFile } from '../store/writer';
|
|
6
|
+
import { sessionDir } from '../store/paths';
|
|
7
|
+
import { makeConfig, withTempDirAsync } from '../test/tmp';
|
|
8
|
+
import { exportSession, exportStore } from './exporter';
|
|
9
|
+
|
|
10
|
+
function turn(i: number, over: Partial<Turn> = {}): Turn {
|
|
11
|
+
return {
|
|
12
|
+
schema: 'session-store/turn@1',
|
|
13
|
+
session_id: 's1',
|
|
14
|
+
host: 'h',
|
|
15
|
+
agent: 'claude-code',
|
|
16
|
+
turn_index: i,
|
|
17
|
+
ts: `2026-06-20T08:0${i}:00Z`,
|
|
18
|
+
role: 'assistant',
|
|
19
|
+
text: '',
|
|
20
|
+
tool_calls: [],
|
|
21
|
+
usage: { input_tokens: 100, output_tokens: 10, cache_read_tokens: 0, cache_write_tokens: 0 },
|
|
22
|
+
scrubbed: false,
|
|
23
|
+
raw_ref: null,
|
|
24
|
+
...over,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function seed(store: string): string {
|
|
29
|
+
const dir = sessionDir(store, 'h', '2026-06', 's1');
|
|
30
|
+
writeTurnFile(dir, turn(0, { role: 'user', text: 'hi' }));
|
|
31
|
+
writeTurnFile(dir, turn(1, { telemetry: { cost_usd: 0.3 } }));
|
|
32
|
+
rebuildEnvelope(store, 'h', '2026-06', 's1', { model: 'claude-opus-4-8' }, {
|
|
33
|
+
session_id: 's1',
|
|
34
|
+
host: 'h',
|
|
35
|
+
agent: 'claude-code',
|
|
36
|
+
native_uuid: 's1',
|
|
37
|
+
});
|
|
38
|
+
return dir;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function withCollector<T>(fn: (endpoint: string, paths: string[]) => Promise<T>): Promise<T> {
|
|
42
|
+
const paths: string[] = [];
|
|
43
|
+
const server = createServer((req, res) => {
|
|
44
|
+
let body = '';
|
|
45
|
+
req.on('data', (c) => (body += c));
|
|
46
|
+
req.on('end', () => {
|
|
47
|
+
paths.push(req.url ?? '');
|
|
48
|
+
res.writeHead(200).end('{}');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
await new Promise<void>((r) => server.listen(0, r));
|
|
52
|
+
const port = (server.address() as AddressInfo).port;
|
|
53
|
+
try {
|
|
54
|
+
return await fn(`http://127.0.0.1:${port}`, paths);
|
|
55
|
+
} finally {
|
|
56
|
+
await new Promise<void>((r) => (server as Server).close(() => r()));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('exportSession', () => {
|
|
61
|
+
it('ships traces + metrics to the collector', async () => {
|
|
62
|
+
await withTempDirAsync(async (store) => {
|
|
63
|
+
const dir = seed(store);
|
|
64
|
+
await withCollector(async (endpoint, paths) => {
|
|
65
|
+
const cfg = makeConfig(store, { telemetry: { enabled: true, endpoint } });
|
|
66
|
+
const res = await exportSession(cfg, dir);
|
|
67
|
+
expect(res.ok).toBe(true);
|
|
68
|
+
expect(paths.sort()).toEqual(['/v1/metrics', '/v1/traces']);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('skips when telemetry is disabled', async () => {
|
|
74
|
+
await withTempDirAsync(async (store) => {
|
|
75
|
+
const dir = seed(store);
|
|
76
|
+
const res = await exportSession(makeConfig(store, { telemetry: { enabled: false } }), dir);
|
|
77
|
+
expect(res.skipped).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('is resilient when the collector is down', async () => {
|
|
82
|
+
await withTempDirAsync(async (store) => {
|
|
83
|
+
const dir = seed(store);
|
|
84
|
+
const cfg = makeConfig(store, { telemetry: { enabled: true, endpoint: 'http://127.0.0.1:9', timeoutMs: 400 } });
|
|
85
|
+
const res = await exportSession(cfg, dir);
|
|
86
|
+
expect(res.ok).toBe(false); // failed but did not throw
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('exportStore', () => {
|
|
92
|
+
it('exports every session', async () => {
|
|
93
|
+
await withTempDirAsync(async (store) => {
|
|
94
|
+
seed(store);
|
|
95
|
+
await withCollector(async (endpoint) => {
|
|
96
|
+
const cfg = makeConfig(store, { telemetry: { enabled: true, endpoint } });
|
|
97
|
+
const res = await exportStore(cfg);
|
|
98
|
+
expect(res.total).toBe(1);
|
|
99
|
+
expect(res.exported).toBe(1);
|
|
100
|
+
expect(res.failed).toBe(0);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { safeParseSession, type SessionEnvelope } from '@unpolarize/code-sessions-schema';
|
|
3
|
+
import type { CodeSessionsConfig } from '../config';
|
|
4
|
+
import { envelopeFile } from '../store/paths';
|
|
5
|
+
import { listSessionDirs } from '../store/scan';
|
|
6
|
+
import { readTurns } from '../store/writer';
|
|
7
|
+
import { buildMetricPayload, buildTracePayload, postOtlp, type PostResult } from './otlp';
|
|
8
|
+
|
|
9
|
+
export interface SessionExportResult {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
skipped?: boolean;
|
|
12
|
+
traces?: PostResult;
|
|
13
|
+
metrics?: PostResult;
|
|
14
|
+
reason?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function loadEnvelope(sessionDir: string): SessionEnvelope | undefined {
|
|
18
|
+
const path = envelopeFile(sessionDir);
|
|
19
|
+
if (!existsSync(path)) return undefined;
|
|
20
|
+
try {
|
|
21
|
+
const parsed = safeParseSession(JSON.parse(readFileSync(path, 'utf8')));
|
|
22
|
+
return parsed.success ? parsed.data : undefined;
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Export one session's traces + metrics over OTLP/HTTP. Resilient: never throws. */
|
|
29
|
+
export async function exportSession(
|
|
30
|
+
cfg: CodeSessionsConfig,
|
|
31
|
+
sessionDir: string,
|
|
32
|
+
): Promise<SessionExportResult> {
|
|
33
|
+
if (!cfg.telemetry.enabled) return { ok: false, skipped: true, reason: 'telemetry disabled' };
|
|
34
|
+
const envelope = loadEnvelope(sessionDir);
|
|
35
|
+
if (!envelope) return { ok: false, reason: 'no envelope' };
|
|
36
|
+
const turns = readTurns(sessionDir);
|
|
37
|
+
const { endpoint, serviceName, timeoutMs } = cfg.telemetry;
|
|
38
|
+
|
|
39
|
+
const traces = await postOtlp(endpoint, '/v1/traces', buildTracePayload(envelope, turns, serviceName), timeoutMs);
|
|
40
|
+
const metrics = await postOtlp(endpoint, '/v1/metrics', buildMetricPayload(envelope, turns, serviceName), timeoutMs);
|
|
41
|
+
return { ok: traces.ok && metrics.ok, traces, metrics };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StoreExportResult {
|
|
45
|
+
total: number;
|
|
46
|
+
exported: number;
|
|
47
|
+
failed: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Export telemetry for every session in the store (backfill path). */
|
|
51
|
+
export async function exportStore(
|
|
52
|
+
cfg: CodeSessionsConfig,
|
|
53
|
+
opts: { sinceMonth?: string } = {},
|
|
54
|
+
): Promise<StoreExportResult> {
|
|
55
|
+
const refs = listSessionDirs(cfg.storeDir, opts.sinceMonth ? { sinceMonth: opts.sinceMonth } : {});
|
|
56
|
+
let exported = 0;
|
|
57
|
+
let failed = 0;
|
|
58
|
+
for (const ref of refs) {
|
|
59
|
+
const res = await exportSession(cfg, ref.dir);
|
|
60
|
+
if (res.ok) exported++;
|
|
61
|
+
else if (!res.skipped) failed++;
|
|
62
|
+
}
|
|
63
|
+
return { total: refs.length, exported, failed };
|
|
64
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createServer, type Server } from 'node:http';
|
|
2
|
+
import type { AddressInfo } from 'node:net';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import type { SessionEnvelope, Turn } from '@unpolarize/code-sessions-schema';
|
|
5
|
+
import { buildMetricPayload, buildTracePayload, isoNano, postOtlp } from './otlp';
|
|
6
|
+
|
|
7
|
+
const envelope: SessionEnvelope = {
|
|
8
|
+
schema: 'session-store/session@1',
|
|
9
|
+
session_id: 'sess-otlp',
|
|
10
|
+
host: 'h',
|
|
11
|
+
agent: 'claude-code',
|
|
12
|
+
project_path: '/p',
|
|
13
|
+
model: 'claude-opus-4-8',
|
|
14
|
+
started_at: '2026-06-20T08:00:00Z',
|
|
15
|
+
ended_at: '2026-06-20T08:05:00Z',
|
|
16
|
+
turn_count: 2,
|
|
17
|
+
tool_call_count: 1,
|
|
18
|
+
totals: { input_tokens: 1100, output_tokens: 30, cost_usd: 0.5 },
|
|
19
|
+
title: 'demo',
|
|
20
|
+
labels: [],
|
|
21
|
+
native_ref: { format: 'claude-jsonl', uuid: 'sess-otlp' },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const turns: Turn[] = [
|
|
25
|
+
{
|
|
26
|
+
schema: 'session-store/turn@1',
|
|
27
|
+
session_id: 'sess-otlp',
|
|
28
|
+
host: 'h',
|
|
29
|
+
agent: 'claude-code',
|
|
30
|
+
turn_index: 0,
|
|
31
|
+
ts: '2026-06-20T08:00:00Z',
|
|
32
|
+
role: 'user',
|
|
33
|
+
text: 'hi',
|
|
34
|
+
tool_calls: [],
|
|
35
|
+
usage: { input_tokens: 100, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
|
|
36
|
+
scrubbed: false,
|
|
37
|
+
raw_ref: null,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
schema: 'session-store/turn@1',
|
|
41
|
+
session_id: 'sess-otlp',
|
|
42
|
+
host: 'h',
|
|
43
|
+
agent: 'claude-code',
|
|
44
|
+
turn_index: 1,
|
|
45
|
+
ts: '2026-06-20T08:00:05Z',
|
|
46
|
+
role: 'assistant',
|
|
47
|
+
text: 'ok',
|
|
48
|
+
tool_calls: [{ name: 'Edit' }],
|
|
49
|
+
usage: { input_tokens: 1000, output_tokens: 30, cache_read_tokens: 0, cache_write_tokens: 0 },
|
|
50
|
+
telemetry: { cost_usd: 0.5 },
|
|
51
|
+
scrubbed: false,
|
|
52
|
+
raw_ref: null,
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
describe('isoNano', () => {
|
|
57
|
+
it('converts ISO timestamps to unix nanoseconds', () => {
|
|
58
|
+
expect(isoNano('2026-06-20T08:00:00Z')).toBe(`${Date.parse('2026-06-20T08:00:00Z')}000000`);
|
|
59
|
+
expect(isoNano(undefined)).toBe('0');
|
|
60
|
+
expect(isoNano('not-a-date')).toBe('0');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('buildTracePayload', () => {
|
|
65
|
+
it('emits a root span + one span per turn with valid ids', () => {
|
|
66
|
+
const p = buildTracePayload(envelope, turns, 'code-sessions') as any;
|
|
67
|
+
const spans = p.resourceSpans[0].scopeSpans[0].spans;
|
|
68
|
+
expect(spans).toHaveLength(3); // root + 2 turns
|
|
69
|
+
const [root, ...turnSpans] = spans;
|
|
70
|
+
expect(root.traceId).toMatch(/^[0-9a-f]{32}$/);
|
|
71
|
+
expect(root.spanId).toMatch(/^[0-9a-f]{16}$/);
|
|
72
|
+
expect(turnSpans.every((s: any) => s.parentSpanId === root.spanId)).toBe(true);
|
|
73
|
+
// resource carries service + host
|
|
74
|
+
const attrs = p.resourceSpans[0].resource.attributes;
|
|
75
|
+
expect(attrs.find((a: any) => a.key === 'service.name').value.stringValue).toBe('code-sessions');
|
|
76
|
+
expect(attrs.find((a: any) => a.key === 'host.name').value.stringValue).toBe('h');
|
|
77
|
+
// root carries token totals
|
|
78
|
+
const inTok = root.attributes.find((a: any) => a.key === 'gen_ai.usage.input_tokens');
|
|
79
|
+
expect(inTok.value.intValue).toBe(1100);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('buildMetricPayload', () => {
|
|
84
|
+
it('emits token sum + cost gauge + turn count', () => {
|
|
85
|
+
const p = buildMetricPayload(envelope, turns, 'code-sessions') as any;
|
|
86
|
+
const metrics = p.resourceMetrics[0].scopeMetrics[0].metrics;
|
|
87
|
+
const byName = Object.fromEntries(metrics.map((m: any) => [m.name, m]));
|
|
88
|
+
expect(byName['code_sessions.tokens'].sum.dataPoints).toHaveLength(4);
|
|
89
|
+
expect(byName['code_sessions.cost_usd'].gauge.dataPoints[0].asDouble).toBe(0.5);
|
|
90
|
+
expect(byName['code_sessions.turns'].sum.dataPoints[0].asInt).toBe(2);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('postOtlp', () => {
|
|
95
|
+
it('posts JSON to the collector and reports success', async () => {
|
|
96
|
+
const received: { path: string; body: string }[] = [];
|
|
97
|
+
const server = createServer((req, res) => {
|
|
98
|
+
let body = '';
|
|
99
|
+
req.on('data', (c) => (body += c));
|
|
100
|
+
req.on('end', () => {
|
|
101
|
+
received.push({ path: req.url ?? '', body });
|
|
102
|
+
res.writeHead(200).end('{}');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
await new Promise<void>((r) => server.listen(0, r));
|
|
106
|
+
const port = (server.address() as AddressInfo).port;
|
|
107
|
+
try {
|
|
108
|
+
const res = await postOtlp(`http://127.0.0.1:${port}`, '/v1/traces', { hello: 1 }, 2000);
|
|
109
|
+
expect(res.ok).toBe(true);
|
|
110
|
+
expect(res.status).toBe(200);
|
|
111
|
+
expect(received[0]!.path).toBe('/v1/traces');
|
|
112
|
+
expect(JSON.parse(received[0]!.body)).toEqual({ hello: 1 });
|
|
113
|
+
} finally {
|
|
114
|
+
await new Promise<void>((r) => (server as Server).close(() => r()));
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('is resilient when the collector is unreachable (no throw)', async () => {
|
|
119
|
+
const res = await postOtlp('http://127.0.0.1:9', '/v1/metrics', {}, 500);
|
|
120
|
+
expect(res.ok).toBe(false);
|
|
121
|
+
expect(res.error).toBeTruthy();
|
|
122
|
+
});
|
|
123
|
+
});
|