forked from ericc-ch/copilot-api
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtrace-writer.ts
More file actions
177 lines (158 loc) · 5.5 KB
/
trace-writer.ts
File metadata and controls
177 lines (158 loc) · 5.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
/**
* Trace JSONL writer (issue #36, F4.A).
*
* Pipeline for every captured event:
* 1. Build the redacted JSONL text (redactHeaders + redactBody +
* JSON.stringify + trailing newline).
* 2. Run assertRedacted against the OUTPUT as a defence-in-depth sanity
* check; if it throws, we drop the trace entirely.
* 3. If retention.traces_days > 0, append the line to today's JSONL file
* with mode 0o600 using the same O_APPEND atomic-write pattern as
* services/audit.ts.
* 4. Always push the (already-redacted, already-asserted) line to the
* broadcaster so the SSE live tail works even when on-disk
* persistence is disabled.
*
* The disk-write step is intentionally synchronous: a partial write would
* leave a malformed JSONL line, and the alternative (queue + async) makes
* crash recovery dramatically harder for what is at best a few KB per
* request.
*/
import consola from "consola"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { getConfig } from "~/lib/config-store"
import { tracesDir } from "~/lib/paths"
import { broadcastTrace } from "./trace-broadcaster"
import { assertRedacted, redactBody, redactHeaders } from "./trace-redact"
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface TraceLeg {
method?: string
url?: string
status?: number
headers: Record<string, string> | Headers
body: string | object | null | undefined
}
export interface TraceEvent {
trace_id: string
ts: number // unix ms
key_id: string // or "__noauth__"
route: string // c.req.path
req: TraceLeg
upstream_req?: TraceLeg
upstream_res?: TraceLeg
res: TraceLeg
latency_ms: number
/**
* Optional per-request metadata. Free-form record so future fields don't
* need a schema bump. Today we use it to surface the default-model
* fallback rewrite (client_requested_model / effective_model / rewritten).
*/
meta?: Record<string, unknown>
}
// ---------------------------------------------------------------------------
// Path helpers
// ---------------------------------------------------------------------------
/** Returns the trace JSONL file path for a given date string (YYYY-MM-DD). */
export function traceFilePath(dateStr: string): string {
return path.join(tracesDir(), `traces-${dateStr}.jsonl`)
}
/** Returns today's date string in YYYY-MM-DD format (local time). */
export function todayDateStr(): string {
const d = new Date()
const yyyy = d.getFullYear()
const mm = String(d.getMonth() + 1).padStart(2, "0")
const dd = String(d.getDate()).padStart(2, "0")
return `${yyyy}-${mm}-${dd}`
}
// ---------------------------------------------------------------------------
// Internal: serialise a leg
// ---------------------------------------------------------------------------
function legToJSON(leg: TraceLeg): Record<string, unknown> {
return {
...(leg.method !== undefined && { method: leg.method }),
...(leg.url !== undefined && { url: leg.url }),
...(leg.status !== undefined && { status: leg.status }),
headers: redactHeaders(leg.headers),
body: redactBody(leg.body),
}
}
function eventToJSON(event: TraceEvent): Record<string, unknown> {
return {
trace_id: event.trace_id,
ts: event.ts,
key_id: event.key_id,
route: event.route,
req: legToJSON(event.req),
...(event.upstream_req && { upstream_req: legToJSON(event.upstream_req) }),
...(event.upstream_res && { upstream_res: legToJSON(event.upstream_res) }),
res: legToJSON(event.res),
latency_ms: event.latency_ms,
...(event.meta && Object.keys(event.meta).length > 0 ?
{ meta: event.meta }
: {}),
}
}
// ---------------------------------------------------------------------------
// writeTrace
// ---------------------------------------------------------------------------
/**
* Persist (when retention is enabled) and broadcast a single trace event.
*
* Best-effort: a failing disk write must never crash the proxied request.
* A failing assertRedacted aborts BOTH the disk write and the broadcast —
* we'd rather lose visibility than persist a known-bad line.
*/
export function writeTrace(event: TraceEvent): void {
let line: string
try {
line = JSON.stringify(eventToJSON(event)) + os.EOL
} catch (err) {
consola.error(`[trace-writer] serialise failed: ${String(err)}`)
return
}
try {
assertRedacted(line)
} catch (err) {
consola.error(
`[trace-writer] redaction sanity check failed, dropping trace: ${String(err)}`,
)
return
}
const cfg = getConfig()
if (cfg.retention.traces_days > 0) {
try {
appendToDisk(line)
} catch (err) {
consola.error(`[trace-writer] append failed (continuing): ${String(err)}`)
}
}
try {
broadcastTrace(line)
} catch (err) {
consola.error(
`[trace-writer] broadcast failed (continuing): ${String(err)}`,
)
}
}
function appendToDisk(line: string): void {
const dir = tracesDir()
// Lazy 0o700 mkdir — same pattern as audit.ts (parent APP_DIR is already
// 0o700; we duplicate the mode here so a fresh checkout still gets the
// restrictive perms).
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
const filePath = traceFilePath(todayDateStr())
const fd = fs.openSync(
filePath,
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND,
0o600,
)
try {
fs.writeSync(fd, line)
} finally {
fs.closeSync(fd)
}
}