@frontera-sdk/cli 1.53.21 → 1.53.23
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,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
|
+
}
|