@frontera-sdk/cli 1.53.21 → 1.53.22
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/package.json +4 -4
- package/src/commands/app/dev.ts +10 -1
- package/src/commands/local/dev.ts +36 -11
- package/src/commands/registry.ts +3 -0
- package/src/commands/telemetry/status.ts +39 -0
- package/src/local/local-supervisor.ts +9 -4
- package/src/main.ts +78 -0
- package/src/mcp/mcp-apps.ts +3 -1
- package/src/mcp/mcp-protocol.ts +33 -2
- package/src/telemetry/agent-events.ts +79 -0
- package/src/telemetry/apply-events.ts +142 -0
- package/src/telemetry/cli-telemetry.ts +102 -0
- package/src/telemetry/device.ts +64 -0
- package/src/telemetry/enabled.ts +49 -0
- package/src/telemetry/events.ts +294 -0
- package/src/telemetry/fail-open.ts +44 -0
- package/src/telemetry/otlp.ts +112 -0
- package/src/telemetry/outbox.ts +175 -0
- package/src/telemetry/recorder.ts +114 -0
- package/src/telemetry/redact.ts +108 -0
- package/src/telemetry/send.ts +121 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { agentAttributes, isAgentService, type AgentService } from './agent-events'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The telemetry event catalog, version 1 (design: docs/superpowers/specs/2026-11-02-frontera-telemetry-design.md).
|
|
5
|
+
*
|
|
6
|
+
* One table lists every event and the attributes it may carry. The event types,
|
|
7
|
+
* the attribute allowlist the collector is checked against, and the parse of an
|
|
8
|
+
* outbox line all read it, so adding an attribute is one change here and one to
|
|
9
|
+
* the collector's allowlist.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
type Kind = 'string' | 'int' | 'bool' | readonly string[]
|
|
13
|
+
|
|
14
|
+
type ValueOf<K> = K extends 'string' ? string : K extends 'int' ? number : K extends 'bool' ? boolean : K extends readonly (infer V)[] ? V : never
|
|
15
|
+
|
|
16
|
+
type Fields<S> = { -readonly [K in keyof S]: ValueOf<S[K]> }
|
|
17
|
+
|
|
18
|
+
interface EventSpec {
|
|
19
|
+
readonly required: Readonly<Record<string, Kind>>
|
|
20
|
+
readonly optional: Readonly<Record<string, Kind>>
|
|
21
|
+
/** Whether a failure of this event carries `error.code`, `error.template` and `error.fingerprint`. */
|
|
22
|
+
readonly error: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const HARNESSES = ['claude', 'codex', 'grok', 'opencode'] as const
|
|
26
|
+
export type Harness = (typeof HARNESSES)[number]
|
|
27
|
+
|
|
28
|
+
export const VIA = ['forge', 'agent', 'terminal', 'ci'] as const
|
|
29
|
+
export type Via = (typeof VIA)[number]
|
|
30
|
+
|
|
31
|
+
export const APPLY_OUTCOMES = ['applied', 'failed', 'runtime_down', 'stopped'] as const
|
|
32
|
+
export type ApplyOutcome = (typeof APPLY_OUTCOMES)[number]
|
|
33
|
+
|
|
34
|
+
/** What started a round of applies in `local dev`. */
|
|
35
|
+
export const APPLY_TRIGGERS = ['files_changed', 'runtime_started', 'dev_started', 'once', 'runtime_answering', 'restore'] as const
|
|
36
|
+
export type ApplyTrigger = (typeof APPLY_TRIGGERS)[number]
|
|
37
|
+
|
|
38
|
+
/** What ended, for `frontera.cli.process_exit`: the watcher, an App's dev server, the runtime, or one of its components. */
|
|
39
|
+
export const EXIT_WHAT = ['local_dev', 'app_dev', 'runtime', 'postgres', 'storage', 'service', 'jobs', 'runner', 'studio'] as const
|
|
40
|
+
export type ExitWhat = (typeof EXIT_WHAT)[number]
|
|
41
|
+
|
|
42
|
+
export const SEVERITIES = { INFO: 9, WARN: 13, ERROR: 17 } as const
|
|
43
|
+
/** `INFO` for success, `WARN` for refused or stopped, `ERROR` for failure. */
|
|
44
|
+
export type Severity = keyof typeof SEVERITIES
|
|
45
|
+
|
|
46
|
+
/** Attributes any event may carry, set from the process that records it. */
|
|
47
|
+
export const RECORD_ATTRIBUTES = {
|
|
48
|
+
'frontera.user.id': 'string',
|
|
49
|
+
'frontera.session.id': 'string',
|
|
50
|
+
'frontera.harness': HARNESSES,
|
|
51
|
+
'frontera.invocation.id': 'string',
|
|
52
|
+
'frontera.project.hash': 'string',
|
|
53
|
+
} as const satisfies Record<string, Kind>
|
|
54
|
+
|
|
55
|
+
export const ERROR_ATTRIBUTES = ['error.code', 'error.template', 'error.fingerprint'] as const
|
|
56
|
+
|
|
57
|
+
export const CATALOG = {
|
|
58
|
+
'frontera.cli.command': {
|
|
59
|
+
required: { 'frontera.noun': 'string', 'frontera.verb': 'string', 'frontera.flags': 'string', 'frontera.exit_code': 'int', 'frontera.via': VIA, duration_ms: 'int' },
|
|
60
|
+
optional: {},
|
|
61
|
+
error: true,
|
|
62
|
+
},
|
|
63
|
+
'frontera.cli.mcp_tool': {
|
|
64
|
+
required: { 'frontera.tool': 'string', 'frontera.ok': 'bool', duration_ms: 'int' },
|
|
65
|
+
optional: {},
|
|
66
|
+
error: true,
|
|
67
|
+
},
|
|
68
|
+
'frontera.cli.apply': {
|
|
69
|
+
required: { 'frontera.item_kind': 'string', 'frontera.item_hash': 'string', 'frontera.outcome': APPLY_OUTCOMES, 'frontera.attempt': 'int', 'frontera.trigger': APPLY_TRIGGERS, duration_ms: 'int' },
|
|
70
|
+
optional: { 'frontera.recovered_after_attempts': 'int', 'frontera.recovered_after_ms': 'int' },
|
|
71
|
+
error: true,
|
|
72
|
+
},
|
|
73
|
+
'frontera.cli.apply_round': {
|
|
74
|
+
required: { 'frontera.trigger': APPLY_TRIGGERS, 'frontera.queued': 'int', 'frontera.applied': 'int', 'frontera.failed': 'int', duration_ms: 'int' },
|
|
75
|
+
optional: {},
|
|
76
|
+
error: false,
|
|
77
|
+
},
|
|
78
|
+
'frontera.cli.process_exit': {
|
|
79
|
+
required: { 'frontera.what': EXIT_WHAT, 'frontera.expected': 'bool', duration_ms: 'int' },
|
|
80
|
+
optional: { 'frontera.exit_code': 'int', 'frontera.signal': 'string' },
|
|
81
|
+
error: false,
|
|
82
|
+
},
|
|
83
|
+
'frontera.forge.start': {
|
|
84
|
+
required: { 'frontera.projects': 'int', 'frontera.sessions': 'int', 'frontera.harnesses_found': 'string' },
|
|
85
|
+
optional: {},
|
|
86
|
+
error: false,
|
|
87
|
+
},
|
|
88
|
+
'frontera.forge.session_open': {
|
|
89
|
+
required: { 'frontera.kind': ['agent', 'terminal'], 'frontera.mode': 'string' },
|
|
90
|
+
// Absent for a plain shell.
|
|
91
|
+
optional: { 'frontera.harness': HARNESSES },
|
|
92
|
+
error: false,
|
|
93
|
+
},
|
|
94
|
+
'frontera.forge.agent_turn': {
|
|
95
|
+
required: {
|
|
96
|
+
'frontera.harness': HARNESSES,
|
|
97
|
+
'frontera.status': 'string',
|
|
98
|
+
'frontera.tokens_in': 'int',
|
|
99
|
+
'frontera.tokens_out': 'int',
|
|
100
|
+
'frontera.commands': 'int',
|
|
101
|
+
'frontera.failed_commands': 'int',
|
|
102
|
+
'frontera.frontera_failed': 'int',
|
|
103
|
+
'frontera.repeat_failures': 'int',
|
|
104
|
+
duration_ms: 'int',
|
|
105
|
+
},
|
|
106
|
+
optional: {},
|
|
107
|
+
error: true,
|
|
108
|
+
},
|
|
109
|
+
'frontera.forge.request': {
|
|
110
|
+
required: { 'frontera.route': 'string', 'frontera.method': 'string', 'frontera.status': 'int', duration_ms: 'int' },
|
|
111
|
+
optional: {},
|
|
112
|
+
error: false,
|
|
113
|
+
},
|
|
114
|
+
'frontera.forge.crash': {
|
|
115
|
+
required: { 'frontera.process': ['window', 'server', 'main'] },
|
|
116
|
+
optional: { 'frontera.page': 'string' },
|
|
117
|
+
error: true,
|
|
118
|
+
},
|
|
119
|
+
} as const satisfies Record<`frontera.${string}`, EventSpec>
|
|
120
|
+
|
|
121
|
+
type Catalog = typeof CATALOG
|
|
122
|
+
export type EventName = keyof Catalog
|
|
123
|
+
|
|
124
|
+
export const EVENT_NAMES = Object.keys(CATALOG) as EventName[]
|
|
125
|
+
|
|
126
|
+
export type RecordAttributes = Partial<Fields<typeof RECORD_ATTRIBUTES>>
|
|
127
|
+
|
|
128
|
+
export type AttributesOf<N extends EventName> = Fields<Catalog[N]['required']> & Partial<Fields<Catalog[N]['optional']>> & RecordAttributes
|
|
129
|
+
|
|
130
|
+
/** A failure, as `redact.ts` makes it: never the raw message. */
|
|
131
|
+
export interface ErrorFields {
|
|
132
|
+
code: string
|
|
133
|
+
template: string
|
|
134
|
+
fingerprint: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** One event as code records it. An event name outside the catalog, or an attribute its entry does not list, does not compile. */
|
|
138
|
+
export type TelemetryEvent = {
|
|
139
|
+
[N in EventName]: { name: N; severity: Severity; attributes: AttributesOf<N> } & (Catalog[N]['error'] extends true ? { error?: ErrorFields } : { error?: never })
|
|
140
|
+
}[EventName]
|
|
141
|
+
|
|
142
|
+
export type AttributeValue = string | number | boolean
|
|
143
|
+
|
|
144
|
+
/** Set once per batch. A type, not an interface, so it reads as a plain attribute map. */
|
|
145
|
+
export type Resource = {
|
|
146
|
+
'service.name': 'frontera-cli' | 'frontera-forge'
|
|
147
|
+
'service.version': string
|
|
148
|
+
'deployment.environment': 'release' | 'dev'
|
|
149
|
+
'os.type': string
|
|
150
|
+
'host.arch': string
|
|
151
|
+
'frontera.device.id': string
|
|
152
|
+
'frontera.wsl': boolean
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A coding agent's resource as Forge's relay writes it: the agent's name and version, the Forge session, and this machine's fields as Forge's own. */
|
|
156
|
+
export type AgentResource = Omit<Resource, 'service.name' | 'deployment.environment'> & {
|
|
157
|
+
'service.name': AgentService
|
|
158
|
+
'frontera.session.id': string
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** One line of an outbox file: the event with its attributes flattened, and the resource of the process that wrote it. */
|
|
162
|
+
export interface FronteraRecord {
|
|
163
|
+
resource: Resource
|
|
164
|
+
name: EventName
|
|
165
|
+
severity: Severity
|
|
166
|
+
/** Unix milliseconds. */
|
|
167
|
+
time: number
|
|
168
|
+
attributes: Record<string, AttributeValue>
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A coding agent's own event, kept by its relay (agent-events.ts). */
|
|
172
|
+
export interface AgentRecord {
|
|
173
|
+
resource: AgentResource
|
|
174
|
+
name: string
|
|
175
|
+
severity: Severity
|
|
176
|
+
time: number
|
|
177
|
+
attributes: Record<string, AttributeValue>
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export type OutboxRecord = FronteraRecord | AgentRecord
|
|
181
|
+
|
|
182
|
+
/** Every log record attribute key the catalog allows, for checking the collector's allowlist against. */
|
|
183
|
+
export const ATTRIBUTE_KEYS: readonly string[] = [
|
|
184
|
+
...new Set([
|
|
185
|
+
'event.name',
|
|
186
|
+
...ERROR_ATTRIBUTES,
|
|
187
|
+
...Object.keys(RECORD_ATTRIBUTES),
|
|
188
|
+
...Object.values(CATALOG as Record<string, EventSpec>).flatMap((spec) => [...Object.keys(spec.required), ...Object.keys(spec.optional)]),
|
|
189
|
+
]),
|
|
190
|
+
].sort()
|
|
191
|
+
|
|
192
|
+
function kindOf(name: EventName, key: string): Kind | undefined {
|
|
193
|
+
const spec = CATALOG[name] as EventSpec
|
|
194
|
+
if (spec.error && (ERROR_ATTRIBUTES as readonly string[]).includes(key)) return 'string'
|
|
195
|
+
const table = [spec.required, spec.optional, RECORD_ATTRIBUTES as Record<string, Kind>].find((t) => Object.hasOwn(t, key))
|
|
196
|
+
return table?.[key]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function fits(kind: Kind, value: unknown): value is AttributeValue {
|
|
200
|
+
if (kind === 'string') return typeof value === 'string'
|
|
201
|
+
if (kind === 'int') return typeof value === 'number' && Number.isInteger(value)
|
|
202
|
+
if (kind === 'bool') return typeof value === 'boolean'
|
|
203
|
+
return typeof value === 'string' && kind.includes(value)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The flat attributes of an event: its own, the process's context under them, and its error fields. */
|
|
207
|
+
export function flatAttributes(event: TelemetryEvent, context: RecordAttributes = {}): Record<string, AttributeValue> {
|
|
208
|
+
const flat: Record<string, AttributeValue> = {}
|
|
209
|
+
for (const [key, value] of Object.entries({ ...context, ...event.attributes })) if (value !== undefined) flat[key] = value as AttributeValue
|
|
210
|
+
if (event.error) {
|
|
211
|
+
flat['error.code'] = event.error.code
|
|
212
|
+
flat['error.template'] = event.error.template
|
|
213
|
+
flat['error.fingerprint'] = event.error.fingerprint
|
|
214
|
+
}
|
|
215
|
+
return flat
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Values that leave are short: an error template keeps its own 300
|
|
220
|
+
* characters, any other string 128. A key or an email address that slipped
|
|
221
|
+
* into one is masked, as the collector masks it (its `redaction` processor).
|
|
222
|
+
*/
|
|
223
|
+
const MAX_STRING: Readonly<Record<string, number>> = { 'error.template': 300 }
|
|
224
|
+
export const BLOCKED_VALUES: readonly RegExp[] = [/sk-[A-Za-z0-9_-]{16,}/g, /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g]
|
|
225
|
+
|
|
226
|
+
function clean<V extends AttributeValue>(key: string, value: V): V {
|
|
227
|
+
if (typeof value !== 'string') return value
|
|
228
|
+
const max = MAX_STRING[key] ?? 128
|
|
229
|
+
// Cut before masking: the email pattern backtracks quadratically on a long run of word characters.
|
|
230
|
+
const masked = BLOCKED_VALUES.reduce((text, rule) => text.replace(rule, '****'), value.slice(0, max + 64))
|
|
231
|
+
return masked.slice(0, max) as V
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const HOST_KEYS = ['service.version', 'os.type', 'host.arch', 'frontera.device.id'] as const
|
|
235
|
+
|
|
236
|
+
function parseResource(raw: unknown): Resource | AgentResource | null {
|
|
237
|
+
if (!raw || typeof raw !== 'object') return null
|
|
238
|
+
const r = raw as Record<string, unknown>
|
|
239
|
+
if (!HOST_KEYS.every((k) => typeof r[k] === 'string') || typeof r['frontera.wsl'] !== 'boolean') return null
|
|
240
|
+
const [version, os, arch, device] = HOST_KEYS.map((k) => clean(k, r[k] as string))
|
|
241
|
+
const host = { 'service.version': version!, 'os.type': os!, 'host.arch': arch!, 'frontera.device.id': device!, 'frontera.wsl': r['frontera.wsl'] }
|
|
242
|
+
const service = r['service.name']
|
|
243
|
+
if (isAgentService(service)) {
|
|
244
|
+
const session = r['frontera.session.id']
|
|
245
|
+
return typeof session === 'string' && session ? { 'service.name': service, ...host, 'frontera.session.id': clean('frontera.session.id', session) } : null
|
|
246
|
+
}
|
|
247
|
+
const environment = r['deployment.environment']
|
|
248
|
+
if ((service !== 'frontera-cli' && service !== 'frontera-forge') || (environment !== 'release' && environment !== 'dev')) return null
|
|
249
|
+
return { 'service.name': service, 'service.version': version!, 'deployment.environment': environment, 'os.type': os!, 'host.arch': arch!, 'frontera.device.id': device!, 'frontera.wsl': host['frontera.wsl'] }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** A Frontera event's attributes: those its catalog entry lists, of the right type. Null for a name outside the catalog. */
|
|
253
|
+
function catalogAttributes(name: string, attributes: Readonly<Record<string, unknown>>): Record<string, AttributeValue> | null {
|
|
254
|
+
if (!Object.hasOwn(CATALOG, name)) return null
|
|
255
|
+
const kept: Record<string, AttributeValue> = {}
|
|
256
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
257
|
+
const kind = kindOf(name as EventName, key)
|
|
258
|
+
if (kind && fits(kind, value)) kept[key] = value
|
|
259
|
+
}
|
|
260
|
+
return kept
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* An outbox line, parsed. Files on disk may be cut short by a crash or written
|
|
265
|
+
* by another release, or by hand: a line that is not a catalog event or a
|
|
266
|
+
* kept agent event is dropped, an attribute its event does not list, or of
|
|
267
|
+
* the wrong type, is left out, and every string is cut short and masked.
|
|
268
|
+
*/
|
|
269
|
+
export function parseRecord(line: string): OutboxRecord | null {
|
|
270
|
+
let raw: unknown
|
|
271
|
+
try {
|
|
272
|
+
raw = JSON.parse(line)
|
|
273
|
+
} catch {
|
|
274
|
+
return null
|
|
275
|
+
}
|
|
276
|
+
return recordOf(raw)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** A record as it may leave the laptop, or null; what parseRecord reads and the agents' relay writes. */
|
|
280
|
+
export function recordOf(raw: unknown): OutboxRecord | null {
|
|
281
|
+
if (!raw || typeof raw !== 'object') return null
|
|
282
|
+
const { resource: rawResource, name, severity, time, attributes } = raw as Record<string, unknown>
|
|
283
|
+
if (typeof name !== 'string') return null
|
|
284
|
+
if (typeof severity !== 'string' || !Object.hasOwn(SEVERITIES, severity)) return null
|
|
285
|
+
if (typeof time !== 'number' || !Number.isFinite(time)) return null
|
|
286
|
+
const resource = parseResource(rawResource)
|
|
287
|
+
if (!resource) return null
|
|
288
|
+
const given = attributes && typeof attributes === 'object' ? (attributes as Record<string, unknown>) : {}
|
|
289
|
+
const service = resource['service.name']
|
|
290
|
+
const kept = isAgentService(service) ? agentAttributes(service, name, given) : catalogAttributes(name, given)
|
|
291
|
+
if (!kept) return null
|
|
292
|
+
for (const [key, value] of Object.entries(kept)) kept[key] = clean(key, value)
|
|
293
|
+
return { resource, name, severity: severity as Severity, time, attributes: kept } as OutboxRecord
|
|
294
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Recorder } from './recorder'
|
|
2
|
+
|
|
3
|
+
const OFF: Recorder = { mode: { mode: 'off', reason: 'switched_off' }, deviceId: '', record() {}, write() {}, roll() {}, close() {}, hash: () => '' }
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The recorder an app records through, made where the app makes it (Forge's
|
|
7
|
+
* server, the `frontera` process): if making it or any call on it throws,
|
|
8
|
+
* the event is lost and the work goes on. A recorder that cannot be made
|
|
9
|
+
* records nothing.
|
|
10
|
+
*/
|
|
11
|
+
export function failOpen(make: () => Recorder): Recorder {
|
|
12
|
+
let made: Recorder
|
|
13
|
+
let deviceId: string
|
|
14
|
+
try {
|
|
15
|
+
made = make()
|
|
16
|
+
deviceId = made.deviceId
|
|
17
|
+
} catch {
|
|
18
|
+
return OFF
|
|
19
|
+
}
|
|
20
|
+
const quiet =
|
|
21
|
+
<A extends unknown[]>(call: (...args: A) => void) =>
|
|
22
|
+
(...args: A) => {
|
|
23
|
+
try {
|
|
24
|
+
call(...args)
|
|
25
|
+
} catch {
|
|
26
|
+
// Telemetry never fails the work it records.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
mode: made.mode,
|
|
31
|
+
deviceId,
|
|
32
|
+
record: quiet((event) => made.record(event)),
|
|
33
|
+
write: quiet((record) => made.write(record)),
|
|
34
|
+
roll: quiet(() => made.roll()),
|
|
35
|
+
close: quiet(() => made.close()),
|
|
36
|
+
hash: (...parts) => {
|
|
37
|
+
try {
|
|
38
|
+
return made.hash(...parts)
|
|
39
|
+
} catch {
|
|
40
|
+
return ''
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { SEVERITIES, type AttributeValue, type OutboxRecord, type Severity } from './events'
|
|
2
|
+
|
|
3
|
+
/** OTLP/JSON `AnyValue`. int64 is a decimal string, as the protobuf JSON mapping writes it. */
|
|
4
|
+
type AnyValue = { stringValue: string } | { intValue: string } | { doubleValue: number } | { boolValue: boolean }
|
|
5
|
+
interface KeyValue {
|
|
6
|
+
key: string
|
|
7
|
+
value: AnyValue
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface OtlpLogsBody {
|
|
11
|
+
resourceLogs: Array<{
|
|
12
|
+
resource: { attributes: KeyValue[] }
|
|
13
|
+
scopeLogs: Array<{
|
|
14
|
+
scope: { name: string; version: string }
|
|
15
|
+
logRecords: Array<{
|
|
16
|
+
timeUnixNano: string
|
|
17
|
+
observedTimeUnixNano: string
|
|
18
|
+
severityNumber: number
|
|
19
|
+
severityText: string
|
|
20
|
+
body: { stringValue: string }
|
|
21
|
+
attributes: KeyValue[]
|
|
22
|
+
}>
|
|
23
|
+
}>
|
|
24
|
+
}>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const SCOPE = { name: 'frontera-telemetry', version: '1' } as const
|
|
28
|
+
|
|
29
|
+
function anyValue(value: AttributeValue): AnyValue {
|
|
30
|
+
if (typeof value === 'boolean') return { boolValue: value }
|
|
31
|
+
if (typeof value === 'number') return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value }
|
|
32
|
+
return { stringValue: value }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const keyValues = (attributes: Readonly<Record<string, AttributeValue>>): KeyValue[] => Object.entries(attributes).map(([key, value]) => ({ key, value: anyValue(value) }))
|
|
36
|
+
|
|
37
|
+
const nanos = (ms: number) => `${BigInt(Math.round(ms)) * 1_000_000n}`
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Records as an OTLP/HTTP JSON logs request: one `resourceLogs` entry per
|
|
41
|
+
* distinct resource, in the order first seen; one log record per event, its
|
|
42
|
+
* body and `event.name` attribute the event name.
|
|
43
|
+
*/
|
|
44
|
+
export function toOtlpLogs(records: readonly OutboxRecord[], observedAt: number = Date.now()): OtlpLogsBody {
|
|
45
|
+
const byResource = new Map<string, OtlpLogsBody['resourceLogs'][number]>()
|
|
46
|
+
for (const record of records) {
|
|
47
|
+
const key = JSON.stringify(Object.entries(record.resource).sort(([a], [b]) => a.localeCompare(b)))
|
|
48
|
+
let entry = byResource.get(key)
|
|
49
|
+
if (!entry) {
|
|
50
|
+
entry = { resource: { attributes: keyValues(record.resource) }, scopeLogs: [{ scope: { ...SCOPE }, logRecords: [] }] }
|
|
51
|
+
byResource.set(key, entry)
|
|
52
|
+
}
|
|
53
|
+
entry.scopeLogs[0]!.logRecords.push({
|
|
54
|
+
timeUnixNano: nanos(record.time),
|
|
55
|
+
observedTimeUnixNano: nanos(observedAt),
|
|
56
|
+
severityNumber: SEVERITIES[record.severity],
|
|
57
|
+
severityText: record.severity,
|
|
58
|
+
body: { stringValue: record.name },
|
|
59
|
+
attributes: keyValues({ 'event.name': record.name, ...record.attributes }),
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
return { resourceLogs: [...byResource.values()] }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One log record of an OTLP/HTTP JSON request, its attributes as plain values (nested and byte values left out). */
|
|
66
|
+
export interface OtlpLogRecord {
|
|
67
|
+
resource: Record<string, AttributeValue>
|
|
68
|
+
/** Unix ms: when it happened, else when it was seen, else now. */
|
|
69
|
+
time: number
|
|
70
|
+
severity: Severity
|
|
71
|
+
attributes: Record<string, AttributeValue>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type Json = Record<string, unknown>
|
|
75
|
+
const list = (value: unknown): Json[] => (Array.isArray(value) ? value.filter((v): v is Json => !!v && typeof v === 'object') : [])
|
|
76
|
+
|
|
77
|
+
function plain(value: unknown): AttributeValue | undefined {
|
|
78
|
+
if (!value || typeof value !== 'object') return undefined
|
|
79
|
+
const v = value as Json
|
|
80
|
+
if (typeof v.stringValue === 'string') return v.stringValue
|
|
81
|
+
if (typeof v.boolValue === 'boolean') return v.boolValue
|
|
82
|
+
if (typeof v.doubleValue === 'number') return v.doubleValue
|
|
83
|
+
const int = typeof v.intValue === 'string' || typeof v.intValue === 'number' ? Number(v.intValue) : Number.NaN
|
|
84
|
+
return Number.isSafeInteger(int) ? int : undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function attributesOf(value: unknown): Record<string, AttributeValue> {
|
|
88
|
+
const out: Record<string, AttributeValue> = {}
|
|
89
|
+
for (const kv of list(value)) {
|
|
90
|
+
const v = plain(kv.value)
|
|
91
|
+
if (typeof kv.key === 'string' && v !== undefined) out[kv.key] = v
|
|
92
|
+
}
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const millis = (nanos: unknown): number | null => {
|
|
97
|
+
const n = typeof nanos === 'string' || typeof nanos === 'number' ? Number(nanos) : 0
|
|
98
|
+
return Number.isFinite(n) && n > 0 ? Math.round(n / 1_000_000) : null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** An OTLP severity number as the catalog's three: 17 and above ERROR, 13 and above WARN, else INFO. */
|
|
102
|
+
const severityOf = (n: unknown): Severity => (typeof n !== 'number' ? 'INFO' : n >= SEVERITIES.ERROR ? 'ERROR' : n >= SEVERITIES.WARN ? 'WARN' : 'INFO')
|
|
103
|
+
|
|
104
|
+
/** The log records of an OTLP/HTTP JSON logs request (what toOtlpLogs writes, and what other programs' exporters send). */
|
|
105
|
+
export function fromOtlpLogs(body: unknown, now: number = Date.now()): OtlpLogRecord[] {
|
|
106
|
+
return list((body as Json | null)?.resourceLogs).flatMap((logs) => {
|
|
107
|
+
const resource = attributesOf((logs.resource as Json | undefined)?.attributes)
|
|
108
|
+
return list(logs.scopeLogs).flatMap((scope) =>
|
|
109
|
+
list(scope.logRecords).map((r) => ({ resource, time: millis(r.timeUnixNano) ?? millis(r.observedTimeUnixNano) ?? now, severity: severityOf(r.severityNumber), attributes: attributesOf(r.attributes) })),
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { appendFileSync, readdirSync, renameSync, statSync, unlinkSync, utimesSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The outbox: a folder of JSON-lines files, one event per line.
|
|
7
|
+
*
|
|
8
|
+
* Each writer owns its own file, so concurrent CLI runs never share one and
|
|
9
|
+
* need no lock. A file's state is its suffix: written to as `<id>.jsonl.open`,
|
|
10
|
+
* closed as `<id>.jsonl` (the only state the sender reads), and claimed by the
|
|
11
|
+
* sender as `<id>.jsonl.sending` until the collector takes it. Every step is a
|
|
12
|
+
* rename, so a crash at any point leaves each file in one whole state.
|
|
13
|
+
*
|
|
14
|
+
* The folder is Forge's: it makes it while telemetry is on and removes it when
|
|
15
|
+
* the switch goes off. No writer ever makes it, so removing it stops every
|
|
16
|
+
* writer at once, including processes started while it was on.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const SUFFIX = { open: '.jsonl.open', closed: '.jsonl', sending: '.jsonl.sending' } as const
|
|
20
|
+
export type FileState = keyof typeof SUFFIX
|
|
21
|
+
|
|
22
|
+
export interface OutboxFile {
|
|
23
|
+
/** `<unix ms>-<uuid>`: unique per file, oldest first when sorted by `time`. */
|
|
24
|
+
id: string
|
|
25
|
+
state: FileState
|
|
26
|
+
bytes: number
|
|
27
|
+
/** Unix ms the file was made (from its id). */
|
|
28
|
+
time: number
|
|
29
|
+
mtimeMs: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const OUTBOX_LIMITS = {
|
|
33
|
+
/** Past this, the oldest closed files are dropped. */
|
|
34
|
+
maxBytes: 50 * 1024 * 1024,
|
|
35
|
+
/** Closed files older than this are dropped. */
|
|
36
|
+
maxAgeMs: 14 * 24 * 60 * 60_000,
|
|
37
|
+
/** A `.sending` file this old belongs to a sender that crashed between the post and the delete: put back. */
|
|
38
|
+
staleSendingMs: 10 * 60_000,
|
|
39
|
+
/** A `.open` file untouched this long belongs to a writer that died without closing it: closed for it. */
|
|
40
|
+
staleOpenMs: 10 * 60_000,
|
|
41
|
+
/** A writer closes its file at this size and starts another, so one file always fits one request. */
|
|
42
|
+
rollBytes: 256 * 1024,
|
|
43
|
+
/** A long-lived writer closes its file this often, so the sender sees its events while it runs. */
|
|
44
|
+
rollMs: 60_000,
|
|
45
|
+
} as const
|
|
46
|
+
|
|
47
|
+
const pathOf = (dir: string, id: string, state: FileState) => join(dir, `${id}${SUFFIX[state]}`)
|
|
48
|
+
|
|
49
|
+
function stateOf(name: string): { id: string; state: FileState } | null {
|
|
50
|
+
for (const state of ['sending', 'open', 'closed'] as const) {
|
|
51
|
+
if (name.endsWith(SUFFIX[state])) return { id: name.slice(0, -SUFFIX[state].length), state }
|
|
52
|
+
}
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every outbox file, oldest first. A missing folder is an empty outbox. */
|
|
57
|
+
export function listOutbox(dir: string): OutboxFile[] {
|
|
58
|
+
let names: string[]
|
|
59
|
+
try {
|
|
60
|
+
names = readdirSync(dir)
|
|
61
|
+
} catch {
|
|
62
|
+
return []
|
|
63
|
+
}
|
|
64
|
+
const files: OutboxFile[] = []
|
|
65
|
+
for (const name of names) {
|
|
66
|
+
const parsed = stateOf(name)
|
|
67
|
+
if (!parsed) continue
|
|
68
|
+
try {
|
|
69
|
+
const stat = statSync(join(dir, name))
|
|
70
|
+
const time = Number.parseInt(parsed.id, 10)
|
|
71
|
+
files.push({ ...parsed, bytes: stat.size, mtimeMs: stat.mtimeMs, time: Number.isFinite(time) ? time : stat.mtimeMs })
|
|
72
|
+
} catch {
|
|
73
|
+
// Claimed or deleted since the listing.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return files.sort((a, b) => a.time - b.time || a.id.localeCompare(b.id))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function move(dir: string, id: string, from: FileState, to: FileState, toId = id): boolean {
|
|
80
|
+
try {
|
|
81
|
+
renameSync(pathOf(dir, id, from), pathOf(dir, toId, to))
|
|
82
|
+
return true
|
|
83
|
+
} catch {
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Claim a closed file for sending. False when it is gone (another sender took
|
|
90
|
+
* it). Touched, so the 10 minutes after which a claim counts as left by a
|
|
91
|
+
* crashed sender run from the claim, not from the file's last write.
|
|
92
|
+
*/
|
|
93
|
+
export function claim(dir: string, id: string, now: number = Date.now()): boolean {
|
|
94
|
+
if (!move(dir, id, 'closed', 'sending')) return false
|
|
95
|
+
try {
|
|
96
|
+
utimesSync(pathOf(dir, id, 'sending'), now / 1000, now / 1000)
|
|
97
|
+
} catch {
|
|
98
|
+
// Taken back since; the claim still holds or is gone, either way nothing to touch.
|
|
99
|
+
}
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Give a claimed file back after a failed send. */
|
|
104
|
+
export const putBack = (dir: string, id: string) => move(dir, id, 'sending', 'closed')
|
|
105
|
+
|
|
106
|
+
/** Delete a file the collector took, or one that can never be sent. */
|
|
107
|
+
export function remove(dir: string, id: string, state: FileState = 'sending'): void {
|
|
108
|
+
try {
|
|
109
|
+
unlinkSync(pathOf(dir, id, state))
|
|
110
|
+
} catch {
|
|
111
|
+
// Already gone.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function readPath(dir: string, file: Pick<OutboxFile, 'id' | 'state'>): string {
|
|
116
|
+
return pathOf(dir, file.id, file.state)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Converge after crashes: a `.sending` file older than 10 minutes goes back
|
|
121
|
+
* (its sender died between the post and the delete, so it is sent again), and
|
|
122
|
+
* a `.open` file untouched for 10 minutes is closed for the writer that died.
|
|
123
|
+
* It is closed under a new id: a writer that was only slow, and appends again,
|
|
124
|
+
* then starts a new file of the old name instead of overwriting this one.
|
|
125
|
+
*/
|
|
126
|
+
export function recoverStale(dir: string, now: number): void {
|
|
127
|
+
for (const file of listOutbox(dir)) {
|
|
128
|
+
if (now - file.mtimeMs < (file.state === 'open' ? OUTBOX_LIMITS.staleOpenMs : OUTBOX_LIMITS.staleSendingMs)) continue
|
|
129
|
+
if (file.state === 'sending') putBack(dir, file.id)
|
|
130
|
+
if (file.state === 'open') move(dir, file.id, 'open', 'closed', `${file.id}-r${randomUUID().slice(0, 8)}`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Drop closed files older than 14 days, then the oldest until the closed files hold 50 MiB or less. Returns how many were dropped. */
|
|
135
|
+
export function prune(dir: string, now: number, limits: { maxBytes: number; maxAgeMs: number } = OUTBOX_LIMITS): number {
|
|
136
|
+
const closed = listOutbox(dir).filter((f) => f.state === 'closed')
|
|
137
|
+
let total = closed.reduce((sum, f) => sum + f.bytes, 0)
|
|
138
|
+
let dropped = 0
|
|
139
|
+
for (const file of closed) {
|
|
140
|
+
if (now - file.mtimeMs <= limits.maxAgeMs && total <= limits.maxBytes) continue
|
|
141
|
+
remove(dir, file.id, 'closed')
|
|
142
|
+
total -= file.bytes
|
|
143
|
+
dropped += 1
|
|
144
|
+
}
|
|
145
|
+
return dropped
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface OutboxWriter {
|
|
149
|
+
/** Append one line to this writer's open file, starting one when there is none. Throws when the folder is gone. */
|
|
150
|
+
append(line: string): void
|
|
151
|
+
/** Close the open file, if any, so the sender can take it. The next append starts a new one. */
|
|
152
|
+
roll(): void
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function outboxWriter(dir: string, opts: { now?: () => number; rollBytes?: number } = {}): OutboxWriter {
|
|
156
|
+
const now = opts.now ?? Date.now
|
|
157
|
+
const rollBytes = opts.rollBytes ?? OUTBOX_LIMITS.rollBytes
|
|
158
|
+
let current: { id: string; bytes: number } | null = null
|
|
159
|
+
const roll = () => {
|
|
160
|
+
if (!current) return
|
|
161
|
+
const { id } = current
|
|
162
|
+
current = null
|
|
163
|
+
move(dir, id, 'open', 'closed')
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
append(line) {
|
|
167
|
+
current ??= { id: `${now()}-${randomUUID()}`, bytes: 0 }
|
|
168
|
+
const text = `${line}\n`
|
|
169
|
+
appendFileSync(pathOf(dir, current.id, 'open'), text)
|
|
170
|
+
current.bytes += Buffer.byteLength(text)
|
|
171
|
+
if (current.bytes >= rollBytes) roll()
|
|
172
|
+
},
|
|
173
|
+
roll,
|
|
174
|
+
}
|
|
175
|
+
}
|