@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,79 @@
|
|
|
1
|
+
import type { AttributeValue } from './events'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The coding agents' own events, and which of them leave the laptop.
|
|
5
|
+
*
|
|
6
|
+
* Forge points the agents it starts (Claude Code, Codex) at its own loopback
|
|
7
|
+
* relay, never at the collector. The relay keeps what these rules keep and
|
|
8
|
+
* writes it to the outbox, so the sender posts it with Forge's events. The
|
|
9
|
+
* Kata collector applies the same rules again
|
|
10
|
+
* (deployments/kata/telemetry/collector.yaml); a test builds the collector's
|
|
11
|
+
* statements from this file and fails when the two disagree.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The `service.name` each agent's exporter sends (measured): Claude Code, and the `codex app-server` Forge starts. */
|
|
15
|
+
export const AGENT_SERVICES = ['claude-code', 'codex-app-server'] as const
|
|
16
|
+
export type AgentService = (typeof AGENT_SERVICES)[number]
|
|
17
|
+
|
|
18
|
+
export const AGENT_EVENTS: Readonly<Record<AgentService, RegExp>> = {
|
|
19
|
+
'claude-code': /^(user_prompt|api_request|api_error|api_refusal|api_retries_exhausted|tool_result|tool_decision|compaction|subagent_completed|internal_error)$/,
|
|
20
|
+
'codex-app-server': /^codex\.(conversation_starts|user_prompt|api_request|sse_event|tool_decision|tool_result|turn_cost|turn_ttft)$/,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The record attributes kept from an agent: counts, timings, models and
|
|
25
|
+
* outcomes. Never prompts, responses, tool input or output, error text, or
|
|
26
|
+
* the names of people, accounts, hosts, files or MCP servers.
|
|
27
|
+
*/
|
|
28
|
+
export const AGENT_ATTRIBUTES =
|
|
29
|
+
/^(event\.(name|kind|sequence)|frontera\.session\.id|session\.id|conversation\.id|prompt\.id|turn\.(id|interrupted)|prompt_length|model|query_source|speed|effort|reasoning_effort|model_reasoning_effort|cost_usd|usage\.estimated_usd|duration_ms|ttft_ms|attempt|status_code|success|error_type|decision|decision_type|decision_source|source|tool_name|tool_origin|tool_input_size_bytes|tool_result_size_bytes|arguments_length|output_length|input_tokens|output_tokens|cache_read_tokens|cache_creation_tokens|input_token_count|output_token_count|cached_token_count|cache_write_token_count|reasoning_token_count|tool_token_count|approval_policy|sandbox_policy)$/
|
|
30
|
+
|
|
31
|
+
export const MAX_AGENT_ATTRIBUTES = 64
|
|
32
|
+
|
|
33
|
+
/** An MCP tool's name carries its server's, which the person chose: kept only for Frontera's own. */
|
|
34
|
+
export const MCP_TOOL = /^mcp__/
|
|
35
|
+
export const FRONTERA_MCP_TOOL = /^mcp__frontera__/
|
|
36
|
+
/** Codex names an MCP tool by its bare name and its server in `mcp_server` (measured, 0.151): the same rule by the server. */
|
|
37
|
+
export const FRONTERA_MCP_SERVER = 'frontera'
|
|
38
|
+
|
|
39
|
+
const thirdPartyMcp = ({ tool_name: tool, mcp_server: server }: Readonly<Record<string, unknown>>) =>
|
|
40
|
+
typeof tool === 'string' && ((MCP_TOOL.test(tool) && !FRONTERA_MCP_TOOL.test(tool)) || (typeof server === 'string' && server !== '' && server !== FRONTERA_MCP_SERVER))
|
|
41
|
+
|
|
42
|
+
/** Codex's environment for a Forge session (`-c otel.environment="forge:<id>"`), which it sends as the resource attribute `env`. */
|
|
43
|
+
export const FORGE_ENVIRONMENT = /^forge:./
|
|
44
|
+
|
|
45
|
+
export const isAgentService = (service: unknown): service is AgentService => (AGENT_SERVICES as readonly unknown[]).includes(service)
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The Forge session a record belongs to: `frontera.session.id` from
|
|
49
|
+
* `OTEL_RESOURCE_ATTRIBUTES`, or for Codex its `forge:` environment. Null for
|
|
50
|
+
* a record from no Forge session (a copied environment, a program an agent
|
|
51
|
+
* started), which is never kept.
|
|
52
|
+
*/
|
|
53
|
+
export function agentSession(resource: Readonly<Record<string, AttributeValue>>): string | null {
|
|
54
|
+
const session = resource['frontera.session.id']
|
|
55
|
+
if (typeof session === 'string' && session) return session
|
|
56
|
+
const env = resource.env
|
|
57
|
+
if (resource['service.name'] === 'codex-app-server' && typeof env === 'string' && FORGE_ENVIRONMENT.test(env)) return env.replace(/^forge:/, '')
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* An agent record's attributes as they may leave, or null when the event is
|
|
63
|
+
* not one that leaves: only the listed keys with plain values, an MCP tool
|
|
64
|
+
* named `mcp` unless it is Frontera's, at most 64 attributes.
|
|
65
|
+
*/
|
|
66
|
+
export function agentAttributes(service: AgentService, name: string, attributes: Readonly<Record<string, unknown>>): Record<string, AttributeValue> | null {
|
|
67
|
+
if (!AGENT_EVENTS[service].test(name)) return null
|
|
68
|
+
// Codex sends one sse_event per streamed event; only the last of a response carries the token counts.
|
|
69
|
+
if (name === 'codex.sse_event' && attributes['event.kind'] !== 'response.completed') return null
|
|
70
|
+
const masked = thirdPartyMcp(attributes)
|
|
71
|
+
const kept: Record<string, AttributeValue> = {}
|
|
72
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
73
|
+
if (Object.keys(kept).length >= MAX_AGENT_ATTRIBUTES) break
|
|
74
|
+
if (!AGENT_ATTRIBUTES.test(key) || !['string', 'number', 'boolean'].includes(typeof value)) continue
|
|
75
|
+
if (typeof value === 'number' && !Number.isFinite(value)) continue
|
|
76
|
+
kept[key] = key === 'tool_name' && masked ? 'mcp' : (value as AttributeValue)
|
|
77
|
+
}
|
|
78
|
+
return kept
|
|
79
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { writeFileAtomic } from '../fs-atomic'
|
|
5
|
+
import { applyFailureReason } from '../local/local-apply-error'
|
|
6
|
+
import type { SyncTarget } from '../local/local-dev-sync'
|
|
7
|
+
import type { LocalProject } from '../local/local-project'
|
|
8
|
+
import { cliTelemetry } from './cli-telemetry'
|
|
9
|
+
import type { ApplyOutcome, ApplyTrigger, Severity } from './events'
|
|
10
|
+
import type { Recorder } from './recorder'
|
|
11
|
+
import { errorCode, errorFields, outputErrorCode } from './redact'
|
|
12
|
+
|
|
13
|
+
/** An item failing since `since` (unix ms), after `failures` failed applies. */
|
|
14
|
+
export interface Streak {
|
|
15
|
+
failures: number
|
|
16
|
+
since: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One apply's place in its item's error round trip. A failure starts or
|
|
21
|
+
* lengthens the streak; the success after it ends the streak and says how many
|
|
22
|
+
* failed applies and how long it took to go green. An apply that never reached
|
|
23
|
+
* the item's files (the runtime was down, the watchdog stopped it) leaves the
|
|
24
|
+
* streak as it is.
|
|
25
|
+
*/
|
|
26
|
+
export function stepStreak(streak: Streak | undefined, outcome: ApplyOutcome, now: number): { next: Streak | undefined; attempt: number; recovered?: { attempts: number; ms: number } } {
|
|
27
|
+
if (outcome === 'failed') {
|
|
28
|
+
const next = { failures: (streak?.failures ?? 0) + 1, since: streak?.since ?? now }
|
|
29
|
+
return { next, attempt: next.failures }
|
|
30
|
+
}
|
|
31
|
+
const attempt = (streak?.failures ?? 0) + 1
|
|
32
|
+
if (outcome !== 'applied') return { next: streak, attempt }
|
|
33
|
+
return { next: undefined, attempt, ...(streak ? { recovered: { attempts: streak.failures, ms: Math.max(0, now - streak.since) } } : {}) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Kept in the project's local state, so `local dev --once` runs, each its own process, add up to one round trip. */
|
|
37
|
+
export const STREAKS_FILE = 'telemetry-streaks.json'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A streak older than this is forgotten: an item that failed on Friday and
|
|
41
|
+
* was fixed on Monday says nothing about how long a fix takes, so its success
|
|
42
|
+
* carries no round trip, and its next failure starts a new streak.
|
|
43
|
+
*/
|
|
44
|
+
export const STREAK_MAX_MS = 8 * 60 * 60_000
|
|
45
|
+
|
|
46
|
+
function readStreaks(path: string, now: number): Record<string, Streak> {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as Record<string, Streak>
|
|
49
|
+
if (!parsed || typeof parsed !== 'object') return {}
|
|
50
|
+
return Object.fromEntries(Object.entries(parsed).filter(([, s]) => now - s.since <= STREAK_MAX_MS))
|
|
51
|
+
} catch {
|
|
52
|
+
return {}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The strings in a target that are names (its slug, its file, its key): replaced in an error template wherever they appear. */
|
|
57
|
+
export function targetNames(target: SyncTarget): string[] {
|
|
58
|
+
const values = Object.entries(target).flatMap(([k, v]) => (k !== 'kind' && typeof v === 'string' ? [v] : []))
|
|
59
|
+
return [...new Set([...values, ...values.map((v) => v.split(/[:/\\]/).pop()!)])].filter((v) => v !== target.kind)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const SEVERITY: Record<ApplyOutcome, Severity> = { applied: 'INFO', failed: 'ERROR', runtime_down: 'WARN', stopped: 'WARN' }
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `frontera.cli.apply` and `frontera.cli.apply_round` for one `local dev`.
|
|
66
|
+
* Item names never leave: only the item's kind and its salted hash.
|
|
67
|
+
*/
|
|
68
|
+
export function applyEvents(project: LocalProject, opts: { recorder?: Recorder; now?: () => number } = {}) {
|
|
69
|
+
const telemetry = opts.recorder ?? cliTelemetry()
|
|
70
|
+
const now = opts.now ?? Date.now
|
|
71
|
+
const on = telemetry.mode.mode !== 'off'
|
|
72
|
+
const projectHash = on ? telemetry.hash(project.id) : ''
|
|
73
|
+
const streaksPath = join(project.stateDir, STREAKS_FILE)
|
|
74
|
+
// Log mode writes nothing into the project: its streaks live as long as this process.
|
|
75
|
+
const kept = telemetry.mode.mode === 'send'
|
|
76
|
+
let memory: Record<string, Streak> = {}
|
|
77
|
+
const read = () => (kept ? readStreaks(streaksPath, now()) : memory)
|
|
78
|
+
const write = (streaks: Record<string, Streak>) => {
|
|
79
|
+
if (!kept) memory = streaks
|
|
80
|
+
// ponytail: read-modify-write without a lock; a watcher and a `--once` stepping the same file at once can drop one step.
|
|
81
|
+
else writeFileAtomic(streaksPath, `${JSON.stringify(streaks)}\n`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const step = (key: string, outcome: ApplyOutcome) => {
|
|
85
|
+
const streaks = read()
|
|
86
|
+
const stepped = stepStreak(streaks[key], outcome, now())
|
|
87
|
+
if (stepped.next !== streaks[key]) {
|
|
88
|
+
if (stepped.next) streaks[key] = stepped.next
|
|
89
|
+
else delete streaks[key]
|
|
90
|
+
write(streaks)
|
|
91
|
+
}
|
|
92
|
+
return stepped
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
/** `local dev` starts with these items: the streaks of any other (deleted, renamed), and expired ones, are forgotten. */
|
|
97
|
+
keep(keys: ReadonlySet<string>): void {
|
|
98
|
+
if (!on || (kept && !existsSync(streaksPath))) return
|
|
99
|
+
try {
|
|
100
|
+
write(Object.fromEntries(Object.entries(read()).filter(([key]) => keys.has(key))))
|
|
101
|
+
} catch {
|
|
102
|
+
// Telemetry never fails the watcher.
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
item(target: SyncTarget, outcome: ApplyOutcome, trigger: ApplyTrigger, ms: number, failure?: { output: string } | { error: unknown }): void {
|
|
106
|
+
if (!on) return
|
|
107
|
+
try {
|
|
108
|
+
const { attempt, recovered } = step(target.key, outcome)
|
|
109
|
+
const error = !failure
|
|
110
|
+
? undefined
|
|
111
|
+
: 'output' in failure
|
|
112
|
+
? errorFields(outputErrorCode(failure.output), applyFailureReason(failure.output), targetNames(target))
|
|
113
|
+
: errorFields(errorCode((failure.error as { code?: unknown })?.code), failure.error instanceof Error ? failure.error.message : String(failure.error), targetNames(target))
|
|
114
|
+
telemetry.record({
|
|
115
|
+
name: 'frontera.cli.apply',
|
|
116
|
+
severity: SEVERITY[outcome],
|
|
117
|
+
attributes: {
|
|
118
|
+
'frontera.item_kind': target.kind,
|
|
119
|
+
'frontera.item_hash': telemetry.hash(project.id, target.key),
|
|
120
|
+
'frontera.outcome': outcome,
|
|
121
|
+
'frontera.attempt': attempt,
|
|
122
|
+
'frontera.trigger': trigger,
|
|
123
|
+
duration_ms: Math.round(ms),
|
|
124
|
+
'frontera.project.hash': projectHash,
|
|
125
|
+
...(recovered ? { 'frontera.recovered_after_attempts': recovered.attempts, 'frontera.recovered_after_ms': recovered.ms } : {}),
|
|
126
|
+
},
|
|
127
|
+
...(error ? { error } : {}),
|
|
128
|
+
})
|
|
129
|
+
} catch {
|
|
130
|
+
// Telemetry never fails an apply.
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
round(round: { trigger: ApplyTrigger; queued: number; applied: number; failed: number; ms: number }): void {
|
|
134
|
+
if (!on) return
|
|
135
|
+
telemetry.record({
|
|
136
|
+
name: 'frontera.cli.apply_round',
|
|
137
|
+
severity: round.failed ? 'ERROR' : 'INFO',
|
|
138
|
+
attributes: { 'frontera.trigger': round.trigger, 'frontera.queued': round.queued, 'frontera.applied': round.applied, 'frontera.failed': round.failed, duration_ms: Math.round(round.ms), 'frontera.project.hash': projectHash },
|
|
139
|
+
})
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join, resolve } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import pkg from '../../package.json' with { type: 'json' }
|
|
7
|
+
import { findSolutionRoot, localProjectAt, PROJECT_FILES } from '../local/local-project'
|
|
8
|
+
import { forgeSessionOf } from '../provenance'
|
|
9
|
+
import { telemetryMode } from './enabled'
|
|
10
|
+
import { failOpen } from './fail-open'
|
|
11
|
+
import { HARNESSES, type ExitWhat, type Harness, type Severity, type Via } from './events'
|
|
12
|
+
import { createRecorder, hostResource, type Recorder } from './recorder'
|
|
13
|
+
|
|
14
|
+
type Env = Record<string, string | undefined>
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The `frontera` process's recorder: made on first use from this process's
|
|
18
|
+
* environment, `service.name` `frontera-cli`, one invocation id per process.
|
|
19
|
+
*/
|
|
20
|
+
let recorder: Recorder | null = null
|
|
21
|
+
|
|
22
|
+
export function cliTelemetry(): Recorder {
|
|
23
|
+
recorder ??= failOpen(() =>
|
|
24
|
+
createRecorder({
|
|
25
|
+
mode: telemetryMode(process.env),
|
|
26
|
+
resource: { 'service.name': 'frontera-cli', ...cliVersion(process.env, import.meta.dir), ...hostResource(process.env) },
|
|
27
|
+
context: cliContext(process.env),
|
|
28
|
+
}),
|
|
29
|
+
)
|
|
30
|
+
return recorder
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The version a bug report would name (as `frontera --version` says it), and whether it is a release. */
|
|
34
|
+
export function cliVersion(env: Env, dir: string): { 'service.version': string; 'deployment.environment': 'release' | 'dev' } {
|
|
35
|
+
const stamped = env.FRONTERA_CLI_VERSION?.trim()
|
|
36
|
+
if (stamped) return { 'service.version': stamped, 'deployment.environment': 'release' }
|
|
37
|
+
return dir.includes('/node_modules/') ? { 'service.version': pkg.version, 'deployment.environment': 'release' } : { 'service.version': `${pkg.version}-dev`, 'deployment.environment': 'dev' }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const harnessOf = (value: string | null | undefined): Harness | undefined => (HARNESSES as readonly string[]).includes(value ?? '') ? (value as Harness) : undefined
|
|
41
|
+
|
|
42
|
+
export function cliContext(env: Env): { 'frontera.invocation.id': string; 'frontera.session.id'?: string; 'frontera.harness'?: Harness } {
|
|
43
|
+
const session = forgeSessionOf(env)
|
|
44
|
+
const harness = harnessOf(session?.harness)
|
|
45
|
+
return { 'frontera.invocation.id': randomUUID(), ...(session ? { 'frontera.session.id': session.id } : {}), ...(harness ? { 'frontera.harness': harness } : {}) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const truthy = (value: string | undefined) => !!value && !['0', 'false'].includes(value.trim().toLowerCase())
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Who ran this command: a coding agent in a Forge session, a person in a Forge
|
|
52
|
+
* terminal, Forge itself (the runtime and the watcher it starts), CI, or a
|
|
53
|
+
* person at a terminal.
|
|
54
|
+
*/
|
|
55
|
+
export function invocationVia(env: Env): Via {
|
|
56
|
+
const session = forgeSessionOf(env)
|
|
57
|
+
const agent = !!harnessOf(session?.harness) || env.CLAUDECODE === '1'
|
|
58
|
+
if (session) return agent ? 'agent' : 'terminal'
|
|
59
|
+
if (env.FORGE_TELEMETRY_OUTBOX?.trim()) return 'forge'
|
|
60
|
+
if (truthy(env.CI) || truthy(env.GITHUB_ACTIONS)) return 'ci'
|
|
61
|
+
return agent ? 'agent' : 'terminal'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The salted hash of the project `cwd` is in, when it is in one. */
|
|
65
|
+
export function projectHashAt(cwd: string, home: string = homedir()): string | undefined {
|
|
66
|
+
const telemetry = cliTelemetry()
|
|
67
|
+
if (telemetry.mode.mode === 'off') return undefined
|
|
68
|
+
const root = findSolutionRoot(cwd, home)
|
|
69
|
+
const markers = root === resolve(home) ? PROJECT_FILES : [...PROJECT_FILES, '.frontera']
|
|
70
|
+
if (!markers.some((m) => existsSync(join(root, m)))) return undefined
|
|
71
|
+
return telemetry.hash(localProjectAt(root).id)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The exit code of a command the person stopped with Ctrl+C (128 + SIGINT), as a shell reports it. */
|
|
75
|
+
export const INTERRUPTED = 130
|
|
76
|
+
|
|
77
|
+
/** The exit code of a command something else stopped with SIGTERM (128 + SIGTERM): Forge's time limit, an agent's shell timeout. */
|
|
78
|
+
export const TERMINATED = 143
|
|
79
|
+
|
|
80
|
+
/** INFO for success, WARN for refused (bad input, a conflict, no permission) or stopped (Ctrl+C, SIGTERM), ERROR for failure. */
|
|
81
|
+
export function severityOfExit(code: number): Severity {
|
|
82
|
+
return code === 0 ? 'INFO' : (code >= 2 && code <= 4) || code === INTERRUPTED || code === TERMINATED ? 'WARN' : 'ERROR'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** An end nobody asked for is ERROR; one asked for with Ctrl+C is WARN (stopped by the person); any other asked-for end is INFO. */
|
|
86
|
+
export const severityOfEnd = (end: { expected: boolean; signal?: string | null }): Severity => (!end.expected ? 'ERROR' : end.signal === 'SIGINT' ? 'WARN' : 'INFO')
|
|
87
|
+
|
|
88
|
+
/** Process end of something the CLI watches, never thrown. */
|
|
89
|
+
export function recordExit(what: ExitWhat, end: { startedAt: number; expected: boolean; exitCode?: number | null; signal?: string | null }): void {
|
|
90
|
+
cliTelemetry().record({
|
|
91
|
+
name: 'frontera.cli.process_exit',
|
|
92
|
+
severity: severityOfEnd(end),
|
|
93
|
+
attributes: {
|
|
94
|
+
'frontera.what': what,
|
|
95
|
+
'frontera.expected': end.expected,
|
|
96
|
+
duration_ms: Math.max(0, Math.round(Date.now() - end.startedAt)),
|
|
97
|
+
...(typeof end.exitCode === 'number' ? { 'frontera.exit_code': end.exitCode } : {}),
|
|
98
|
+
...(end.signal ? { 'frontera.signal': end.signal } : {}),
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
cliTelemetry().roll()
|
|
102
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createHmac, randomBytes, randomUUID } from 'node:crypto'
|
|
2
|
+
import { linkSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* This machine's telemetry identity: a random id sent as `frontera.device.id`,
|
|
7
|
+
* and a salt that never leaves it, for hashing project ids and item names.
|
|
8
|
+
* Kept in the outbox folder as `device.json`, made on first use.
|
|
9
|
+
*/
|
|
10
|
+
export interface DeviceIdentity {
|
|
11
|
+
id: string
|
|
12
|
+
salt: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DEVICE_FILE = 'device.json'
|
|
16
|
+
|
|
17
|
+
function read(path: string): DeviceIdentity | null {
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<DeviceIdentity>
|
|
20
|
+
return typeof parsed.id === 'string' && parsed.id && typeof parsed.salt === 'string' && parsed.salt ? { id: parsed.id, salt: parsed.salt } : null
|
|
21
|
+
} catch {
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function freshIdentity(): DeviceIdentity {
|
|
27
|
+
return { id: randomUUID(), salt: randomBytes(32).toString('hex') }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read `device.json`, or make it. Two first runs at once agree on one file: each
|
|
32
|
+
* writes its own temporary file whole, and only the first link to the real name
|
|
33
|
+
* wins; the other reads the winner's. A corrupt file is replaced. The folder
|
|
34
|
+
* must exist (Forge makes it while telemetry is on): this throws without it.
|
|
35
|
+
*/
|
|
36
|
+
export function deviceIdentity(dir: string): DeviceIdentity {
|
|
37
|
+
const path = join(dir, DEVICE_FILE)
|
|
38
|
+
const existing = read(path)
|
|
39
|
+
if (existing) return existing
|
|
40
|
+
const made = freshIdentity()
|
|
41
|
+
const tmp = join(dir, `.device-${process.pid}-${randomUUID()}.tmp`)
|
|
42
|
+
writeFileSync(tmp, `${JSON.stringify(made)}\n`, { mode: 0o600 })
|
|
43
|
+
try {
|
|
44
|
+
linkSync(tmp, path)
|
|
45
|
+
return made
|
|
46
|
+
} catch (err) {
|
|
47
|
+
const raced = (err as NodeJS.ErrnoException).code === 'EEXIST' ? read(path) : null
|
|
48
|
+
if (raced) return raced
|
|
49
|
+
// A corrupt file, or a filesystem without hard links: replaced whole.
|
|
50
|
+
renameSync(tmp, path)
|
|
51
|
+
return made
|
|
52
|
+
} finally {
|
|
53
|
+
try {
|
|
54
|
+
unlinkSync(tmp)
|
|
55
|
+
} catch {
|
|
56
|
+
// Renamed into place.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A salted hash of `parts` (a project id; a project id and an item key): 16 hex characters, the same on this machine every time. */
|
|
62
|
+
export function saltedHash(salt: string, ...parts: string[]): string {
|
|
63
|
+
return createHmac('sha256', salt).update(parts.join('\0')).digest('hex').slice(0, 16)
|
|
64
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path'
|
|
2
|
+
|
|
3
|
+
type Env = Record<string, string | undefined>
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether this process records telemetry, and why: the rule in the design's
|
|
7
|
+
* "Who sends". Only Forge puts `FORGE_TELEMETRY_OUTBOX` in a process's
|
|
8
|
+
* environment, so the same CLI in any other terminal writes nothing.
|
|
9
|
+
* `FRONTERA_TELEMETRY=log` prints events to stderr and writes nothing, in or
|
|
10
|
+
* out of Forge, so anyone can see what would be sent.
|
|
11
|
+
*/
|
|
12
|
+
export type TelemetryMode =
|
|
13
|
+
| { mode: 'send'; reason: 'forge'; outbox: string }
|
|
14
|
+
| { mode: 'log'; reason: 'log' }
|
|
15
|
+
| { mode: 'off'; reason: 'do_not_track' | 'switched_off' | 'test_runner' | 'outbox_not_absolute' | 'not_in_forge' }
|
|
16
|
+
|
|
17
|
+
export const REASON_TEXT: Record<TelemetryMode['reason'], string> = {
|
|
18
|
+
forge: 'started by Forge (FORGE_TELEMETRY_OUTBOX is set): events are written to the outbox, and Forge sends them to the Frontera team',
|
|
19
|
+
log: 'FRONTERA_TELEMETRY=log: events are printed to stderr, nothing is written or sent',
|
|
20
|
+
do_not_track: 'DO_NOT_TRACK is set: nothing is recorded',
|
|
21
|
+
switched_off: 'FRONTERA_TELEMETRY switches it off: nothing is recorded',
|
|
22
|
+
test_runner: 'running under a test runner (NODE_ENV=test): nothing is recorded',
|
|
23
|
+
outbox_not_absolute: 'FORGE_TELEMETRY_OUTBOX is not an absolute path: nothing is recorded',
|
|
24
|
+
not_in_forge: 'not started by Forge (FORGE_TELEMETRY_OUTBOX is not set): nothing is recorded or sent',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const OFF_WORDS = new Set(['0', 'false', 'off', 'no'])
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Set by Forge on the `frontera` calls it makes on its own schedule (a screen's
|
|
31
|
+
* poll, a timed fetch): such a call records its `frontera.cli.command` only
|
|
32
|
+
* when it fails, so a poll every minute does not drown what people and agents run.
|
|
33
|
+
*/
|
|
34
|
+
export const BACKGROUND_ENV = 'FORGE_TELEMETRY_BACKGROUND'
|
|
35
|
+
|
|
36
|
+
export const isBackground = (env: Env): boolean => env[BACKGROUND_ENV]?.trim() === '1'
|
|
37
|
+
|
|
38
|
+
export function telemetryMode(env: Env): TelemetryMode {
|
|
39
|
+
const setting = env.FRONTERA_TELEMETRY?.trim().toLowerCase() ?? ''
|
|
40
|
+
const dnt = env.DO_NOT_TRACK?.trim().toLowerCase() ?? ''
|
|
41
|
+
if (dnt && !OFF_WORDS.has(dnt)) return { mode: 'off', reason: 'do_not_track' }
|
|
42
|
+
if (OFF_WORDS.has(setting)) return { mode: 'off', reason: 'switched_off' }
|
|
43
|
+
if (setting === 'log') return { mode: 'log', reason: 'log' }
|
|
44
|
+
if (env.NODE_ENV === 'test') return { mode: 'off', reason: 'test_runner' }
|
|
45
|
+
const outbox = env.FORGE_TELEMETRY_OUTBOX?.trim()
|
|
46
|
+
if (!outbox) return { mode: 'off', reason: 'not_in_forge' }
|
|
47
|
+
if (!isAbsolute(outbox)) return { mode: 'off', reason: 'outbox_not_absolute' }
|
|
48
|
+
return { mode: 'send', reason: 'forge', outbox }
|
|
49
|
+
}
|