@frontera-sdk/cli 1.53.20 → 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.
@@ -0,0 +1,114 @@
1
+ import { flatAttributes, type OutboxRecord, type RecordAttributes, type Resource, type TelemetryEvent } from './events'
2
+ import type { TelemetryMode } from './enabled'
3
+ import { deviceIdentity, freshIdentity, saltedHash, type DeviceIdentity } from './device'
4
+ import { OUTBOX_LIMITS, outboxWriter, type OutboxWriter } from './outbox'
5
+
6
+ type Env = Record<string, string | undefined>
7
+
8
+ /**
9
+ * Records events for one process. Off, it does nothing; in log mode it prints
10
+ * each event to stderr; sending, it appends each to this process's own outbox
11
+ * file, closed every minute and when the process exits.
12
+ *
13
+ * It never throws and never touches the network: a write that fails is
14
+ * dropped, so telemetry can neither fail nor slow the work it describes.
15
+ */
16
+ export interface Recorder {
17
+ readonly mode: TelemetryMode
18
+ readonly deviceId: string
19
+ record(event: TelemetryEvent): void
20
+ /** Append a record another program made, already kept by the rules (the agents' relay in Forge); only while sending. */
21
+ write(record: OutboxRecord): void
22
+ /** Close the open outbox file now, so the sender can take it. */
23
+ roll(): void
24
+ /** Roll, and stop the timer and exit hook of a recorder that is being replaced. */
25
+ close(): void
26
+ /** A salted hash, for a project id or an item: never the name itself. */
27
+ hash(...parts: string[]): string
28
+ }
29
+
30
+ /** `os.type`, `host.arch` and `frontera.wsl` of this machine, as OpenTelemetry's resource conventions name them. */
31
+ export function hostResource(env: Env = process.env, platform: string = process.platform, arch: string = process.arch): Pick<Resource, 'os.type' | 'host.arch' | 'frontera.wsl'> {
32
+ const os: Record<string, string> = { win32: 'windows' }
33
+ const archs: Record<string, string> = { x64: 'amd64', ia32: 'x86', arm: 'arm32' }
34
+ return { 'os.type': os[platform] ?? platform, 'host.arch': archs[arch] ?? arch, 'frontera.wsl': platform === 'linux' && !!(env.WSL_DISTRO_NAME || env.WSL_INTEROP) }
35
+ }
36
+
37
+ export function createRecorder(opts: {
38
+ mode: TelemetryMode
39
+ resource: Omit<Resource, 'frontera.device.id'>
40
+ context?: RecordAttributes
41
+ /** Where log mode prints; stderr by default. */
42
+ print?: (line: string) => void
43
+ now?: () => number
44
+ rollMs?: number
45
+ }): Recorder {
46
+ const { mode } = opts
47
+ const now = opts.now ?? Date.now
48
+ let identity: DeviceIdentity
49
+ try {
50
+ identity = mode.mode === 'send' ? deviceIdentity(mode.outbox) : freshIdentity()
51
+ } catch {
52
+ identity = freshIdentity()
53
+ }
54
+ const resource: Resource = { ...opts.resource, 'frontera.device.id': identity.id }
55
+ const print = opts.print ?? ((line: string) => process.stderr.write(`${line}\n`))
56
+ let writer: OutboxWriter | null = null
57
+ let unhook = () => {}
58
+
59
+ const open = (outbox: string): OutboxWriter => {
60
+ const made = outboxWriter(outbox, { now })
61
+ const timer = setInterval(() => made.roll(), opts.rollMs ?? OUTBOX_LIMITS.rollMs)
62
+ timer.unref()
63
+ const onExit = () => made.roll()
64
+ process.once('exit', onExit)
65
+ unhook = () => {
66
+ clearInterval(timer)
67
+ process.off('exit', onExit)
68
+ }
69
+ return made
70
+ }
71
+
72
+ const roll = () => {
73
+ try {
74
+ writer?.roll()
75
+ } catch {
76
+ // Telemetry never fails the work it records.
77
+ }
78
+ }
79
+
80
+ const write = (record: OutboxRecord) => {
81
+ if (mode.mode !== 'send') return
82
+ try {
83
+ writer ??= open(mode.outbox)
84
+ writer.append(JSON.stringify(record))
85
+ } catch {
86
+ // Telemetry never fails the work it records.
87
+ }
88
+ }
89
+
90
+ return {
91
+ mode,
92
+ deviceId: identity.id,
93
+ record(event) {
94
+ if (mode.mode === 'off') return
95
+ try {
96
+ const attributes = flatAttributes(event, opts.context)
97
+ if (mode.mode === 'log') {
98
+ print(`[telemetry] ${event.name} ${event.severity} ${JSON.stringify(attributes)}`)
99
+ return
100
+ }
101
+ write({ resource, name: event.name, severity: event.severity, time: now(), attributes })
102
+ } catch {
103
+ // Telemetry never fails the work it records.
104
+ }
105
+ },
106
+ write,
107
+ roll,
108
+ close() {
109
+ roll()
110
+ unhook()
111
+ },
112
+ hash: (...parts) => saltedHash(identity.salt, ...parts),
113
+ }
114
+ }
@@ -0,0 +1,108 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ import type { ErrorFields } from './events'
4
+
5
+ const X = '<x>'
6
+ const MAX_TEMPLATE = 300
7
+ const MAX_INPUT = 2_000
8
+
9
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
10
+
11
+ /**
12
+ * What is replaced by `<x>`, in order. The design's list (quoted and
13
+ * backticked text, URLs, emails, UUIDs, paths, numbers, and parenthesised
14
+ * text, which its worked example replaces), plus identifier-shaped words
15
+ * (kebab-case, snake_case, camelCase): item names are written that way, and
16
+ * a message names them without quotes as often as with. Dotted host names go
17
+ * too. Ceiling: a name written as plain words (an organization's display
18
+ * name, a column called Nasabah) is indistinguishable from the message's own
19
+ * words and stays.
20
+ */
21
+ const RULES: readonly RegExp[] = [
22
+ /`[^`]*`/g,
23
+ /\b[a-z][a-z0-9+.-]*:\/\/[^\s"'`<>()]+/gi,
24
+ /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,
25
+ // Host names (a customer's platform or registry), which the CLI and the runtime write unquoted.
26
+ /\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/gi,
27
+ /"[^"\n]*"/g,
28
+ /“[^”\n]*”/g,
29
+ /(?<!\w)'[^'\n]*'(?!\w)/g,
30
+ /(?<!\w)‘[^’\n]*’(?!\w)/g,
31
+ /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi,
32
+ /[\w.@%+~<>-]*(?:[\\/][\w.@%+~<>-]+)+[\\/]?|[\w.@%+~<>-]+[\\/]/g,
33
+ /\b\w+(?:[-_]\w+)+\b/g,
34
+ /\b[a-z]+[A-Z]\w*\b/g,
35
+ /\b[A-Z][a-z]+[A-Z]\w*\b/g,
36
+ /[\w:+-]*\d(?:[\w.:+-]*\w)?/g,
37
+ ]
38
+
39
+ const ERROR_LINE = /^error \[[A-Z][A-Z0-9_]*\]:\s*/
40
+
41
+ /**
42
+ * An error message as a template: no names, paths, ids or numbers, so the same
43
+ * failure groups as one row however many times and wherever it happens.
44
+ * One line only: its `error [CODE]:` line when it has one, else its first.
45
+ * The lines under it are details (what was refused, a command's output), and
46
+ * those are where content would be. `names` are strings the caller knows are
47
+ * names (an item's slug, its file), replaced first wherever they appear.
48
+ */
49
+ export function errorTemplate(message: string, names: readonly string[] = []): string {
50
+ const lines = message.split('\n').map((l) => l.trim()).filter(Boolean)
51
+ // Cut first: the rules backtrack quadratically on a long run of word characters, and a template keeps 300 anyway.
52
+ let text = (lines.find((l) => ERROR_LINE.test(l)) ?? lines[0] ?? '').replace(ERROR_LINE, '').slice(0, MAX_INPUT)
53
+ const known = [...new Set(names.map((n) => n.trim()).filter((n) => n.length > 1))].sort((a, b) => b.length - a.length)
54
+ if (known.length) text = text.replace(new RegExp(`(?<![\\w-])(?:${known.map(escape).join('|')})(?![\\w-])`, 'gi'), X)
55
+ for (const rule of RULES) text = text.replace(rule, X)
56
+ text = text
57
+ .replace(/\([^()]*\)/g, `(${X})`)
58
+ .replace(/(?:<x>)+/g, X)
59
+ .replace(/\s+/g, ' ')
60
+ .trim()
61
+ return text.length > MAX_TEMPLATE ? text.slice(0, MAX_TEMPLATE).replace(/<x?$/, '').trimEnd() : text
62
+ }
63
+
64
+ /** The first 16 hex characters of SHA-256 over `code + template`, for grouping. */
65
+ export function errorFingerprint(code: string, template: string): string {
66
+ return createHash('sha256').update(code + template).digest('hex').slice(0, 16)
67
+ }
68
+
69
+ const CODE = /^[A-Z][A-Z0-9_]*$/
70
+
71
+ /** A code as the CLI and the service name them (`RUNTIME_LOCKED`), else `UNCODED`. */
72
+ export function errorCode(code: unknown): string {
73
+ return typeof code === 'string' && CODE.test(code) ? code : 'UNCODED'
74
+ }
75
+
76
+ /** The code of a command's output: its `error [CODE]` prefix, else `UNCODED`. */
77
+ export function outputErrorCode(output: string): string {
78
+ return errorCode(/error \[([A-Z][A-Z0-9_]*)\]/.exec(output)?.[1])
79
+ }
80
+
81
+ /**
82
+ * A message that is a JSON error, as a harness passes on a provider's
83
+ * (`{"type":"error","status":400,"error":{"code":"model_not_found","message":"…"}}`):
84
+ * its `error.message` or `message`, and its `error.code` or `code`. Null for
85
+ * any other message.
86
+ */
87
+ function jsonError(message: string): { message: string | undefined; code: unknown } | null {
88
+ let parsed: unknown
89
+ try {
90
+ parsed = JSON.parse(message)
91
+ } catch {
92
+ return null
93
+ }
94
+ if (!parsed || typeof parsed !== 'object') return null
95
+ const top = parsed as { message?: unknown; code?: unknown; error?: unknown }
96
+ const inner = top.error && typeof top.error === 'object' ? (top.error as { message?: unknown; code?: unknown }) : {}
97
+ const text = [inner.message, top.message].find((m): m is string => typeof m === 'string' && m.trim() !== '')
98
+ return { message: text, code: inner.code ?? top.code }
99
+ }
100
+
101
+ export function errorFields(code: string, message: string, names: readonly string[] = []): ErrorFields {
102
+ const json = jsonError(message)
103
+ const template = errorTemplate(json?.message ?? message, names)
104
+ let checked = errorCode(code)
105
+ // The caller's code wins; else the JSON's, in the CLI's case (`model_not_found` is MODEL_NOT_FOUND).
106
+ if (checked === 'UNCODED' && typeof json?.code === 'string') checked = errorCode(json.code.toUpperCase())
107
+ return { code: checked, template, fingerprint: errorFingerprint(checked, template) }
108
+ }
@@ -0,0 +1,121 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ import { parseRecord, type OutboxRecord } from './events'
4
+ import { claim, listOutbox, prune, putBack, readPath, recoverStale, remove } from './outbox'
5
+ import { toOtlpLogs } from './otlp'
6
+
7
+ type Env = Record<string, string | undefined>
8
+ type Fetch = (url: string, init: RequestInit) => Promise<Response>
9
+
10
+ export const DEFAULT_ENDPOINT = 'https://telemetry.frontera.kata.ai/v1/logs'
11
+
12
+ /**
13
+ * The public write key: what a build without a key of its own (a checkout)
14
+ * sends with. Anyone can read it here, as with Next.js's or PostHog's public
15
+ * keys; the collector's filters and allowlists and the edge's rate limit
16
+ * protect the store, not its secrecy. `telemetry.sh` keeps it on the
17
+ * collector's token list (`PUBLIC_TOKEN` there, which a test holds equal).
18
+ */
19
+ export const PUBLIC_TOKEN = 'ftt_2ff4debb87da5d9d0571629af15ff328fde0ae07c38d99ea'
20
+
21
+ /** The collector's cap on one request (`max_request_body_size`). */
22
+ export const MAX_BODY_BYTES = 1024 * 1024
23
+ export const SEND_TIMEOUT_MS = 5_000
24
+
25
+ /**
26
+ * How long the sender waits before its next pass, by how many passes in a row
27
+ * have failed: 30 seconds normally, doubling after each failure up to 10 minutes.
28
+ */
29
+ export const SEND_BACKOFF_MS = [30_000, 60_000, 120_000, 240_000, 480_000, 600_000] as const
30
+
31
+ export function sendDelayMs(consecutiveFailures: number): number {
32
+ return SEND_BACKOFF_MS[Math.min(Math.max(0, consecutiveFailures), SEND_BACKOFF_MS.length - 1)]!
33
+ }
34
+
35
+ /**
36
+ * Where to send and with which token: `FRONTERA_TELEMETRY_TOKEN`, else the
37
+ * release build's own key (one per release line, so one line can be revoked
38
+ * alone), else the public key. The overrides are for testing against a local
39
+ * collector.
40
+ */
41
+ export function senderConfig(env: Env, builtInToken: string | undefined): { endpoint: string; token: string } {
42
+ return { endpoint: env.FRONTERA_TELEMETRY_ENDPOINT?.trim() || DEFAULT_ENDPOINT, token: env.FRONTERA_TELEMETRY_TOKEN?.trim() || builtInToken?.trim() || PUBLIC_TOKEN }
43
+ }
44
+
45
+ export type SendResult =
46
+ /** Nothing closed to send. */
47
+ | { outcome: 'idle' }
48
+ /** Taken by the collector, and deleted: the records it took. `more`: closed files are still waiting, so send again now. */
49
+ | { outcome: 'sent'; files: number; records: OutboxRecord[]; more: boolean }
50
+ /** Refused as malformed or too large (400, 413): deleted, since sending it again can never succeed. */
51
+ | { outcome: 'dropped'; files: number; records: OutboxRecord[]; status: number }
52
+ /** Not taken (no answer, a timeout, 401, 429, 5xx): put back, to try after `sendDelayMs`. */
53
+ | { outcome: 'failed'; files: number; status: number | null; error: string | null }
54
+
55
+ const records = (text: string): OutboxRecord[] => text.split('\n').flatMap((line) => (line.trim() ? (parseRecord(line) ?? []) : []))
56
+
57
+ const bodyBytes = (batch: readonly OutboxRecord[], now: number) => Buffer.byteLength(JSON.stringify(toOtlpLogs(batch, now)))
58
+
59
+ /**
60
+ * One pass of the sender, run by Forge's server at start and then after each
61
+ * `sendDelayMs`. Puts back what a crashed pass left claimed, drops what is past
62
+ * the outbox's age and size limits, claims the oldest closed files that fit in
63
+ * one request, posts them, and deletes them on a 2xx or puts them back.
64
+ */
65
+ export async function sendPass(opts: { dir: string; endpoint: string; token: string; fetch?: Fetch; now?: () => number; timeoutMs?: number }): Promise<SendResult> {
66
+ const now = opts.now ?? Date.now
67
+ const { dir } = opts
68
+ recoverStale(dir, now())
69
+ prune(dir, now())
70
+
71
+ const claimed: string[] = []
72
+ const batch: OutboxRecord[] = []
73
+ let bytes = 0
74
+ const closed = listOutbox(dir).filter((f) => f.state === 'closed')
75
+ for (const file of closed) {
76
+ let found: OutboxRecord[]
77
+ try {
78
+ found = records(readFileSync(readPath(dir, file), 'utf8'))
79
+ } catch {
80
+ continue
81
+ }
82
+ // Each file's size as a request of its own: the batch, merging shared resources, is never larger than their sum.
83
+ const size = bodyBytes(found, now())
84
+ // The first file always goes: a writer closes its file well under the cap.
85
+ if (claimed.length && bytes + size > MAX_BODY_BYTES) break
86
+ if (!claim(dir, file.id, now())) continue
87
+ claimed.push(file.id)
88
+ batch.push(...found)
89
+ bytes += size
90
+ }
91
+ if (!claimed.length) return { outcome: 'idle' }
92
+ const more = closed.length > claimed.length
93
+ if (!batch.length) {
94
+ for (const id of claimed) remove(dir, id)
95
+ return { outcome: 'sent', files: claimed.length, records: [], more }
96
+ }
97
+
98
+ let status: number | null = null
99
+ let error: string | null = null
100
+ try {
101
+ const res = await (opts.fetch ?? fetch)(opts.endpoint, {
102
+ method: 'POST',
103
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${opts.token}` },
104
+ body: JSON.stringify(toOtlpLogs(batch, now())),
105
+ signal: AbortSignal.timeout(opts.timeoutMs ?? SEND_TIMEOUT_MS),
106
+ })
107
+ status = res.status
108
+ } catch (err) {
109
+ error = (err as Error).name === 'TimeoutError' ? 'timed out' : (err as Error).message
110
+ }
111
+ if (status !== null && status >= 200 && status < 300) {
112
+ for (const id of claimed) remove(dir, id)
113
+ return { outcome: 'sent', files: claimed.length, records: batch, more }
114
+ }
115
+ if (status === 400 || status === 413) {
116
+ for (const id of claimed) remove(dir, id)
117
+ return { outcome: 'dropped', files: claimed.length, records: batch, status }
118
+ }
119
+ for (const id of claimed) putBack(dir, id)
120
+ return { outcome: 'failed', files: claimed.length, status, error }
121
+ }