mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat: add opt-in Langfuse telemetry (#800)
* docs(specs): add langfuse telemetry change spec
Captures the design for forwarding completed agent runs to Langfuse,
including data-model mapping, field-budget caps, privacy gates,
build-secret injection, GDPR right-to-deletion approach, and the
resolved decisions on default consent, identifier shape, region, and
ownership.
* feat(daemon): add langfuse-trace module and telemetry prefs
Adds the dependency-free building blocks for forwarding completed
agent runs to Langfuse. Two layers:
- AppConfigPrefs gains installationId and a TelemetryPrefs object with
metrics / content / artifactManifest gates. The daemon validator
treats telemetry like agentModels — replace-on-write, drop-when-empty,
reject non-boolean inner values.
- New langfuse-trace.ts builds a {trace-create, generation-create}
pair from a ReportContext, capping prompt at 8 KB, output at 16 KB,
artifacts at 50 entries, and dropping any batch larger than 1 MB
before send. reportRunCompleted is no-op when LANGFUSE_PUBLIC_KEY /
LANGFUSE_SECRET_KEY are unset (so dev runs and forks never emit) and
short-circuits on prefs.metrics === false.
Server-side wiring into the run-close path lands in a follow-up.
* fix(langfuse): default to US Langfuse region
End-to-end smoke against the project's actual dev key on 2026-05-07
returned 401 from cloud.langfuse.com (EU) and 207 from
us.cloud.langfuse.com (US), confirming the org lives in US. Update the
default base URL, the matching test, and the spec's Q3 decision row to
match. Self-hosted or EU-region operators can still override via the
LANGFUSE_BASE_URL env var.
* feat(daemon): wire langfuse trace forwarding into run-close
Adds the daemon-side glue to forward completed agent runs:
- runs.ts gains an optional onTerminate hook fired once per run after it
reaches a terminal state. Errors thrown from the hook are caught and
logged, never propagated, so telemetry can never break the run path.
- New langfuse-bridge.ts assembles a ReportContext from the in-memory
run record, the conversation's persisted assistant message, and the
user's app-config preferences. It tolerates a missing message (e.g.
when web has not yet PUT the final delta) and a missing app-config.
- server.ts stashes the original user prompt on the run object inside
startChatRun so the bridge can include it without crossing the
createChatRunService boundary, and registers the hook callback when
building the run service.
Behavior remains a no-op unless LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY
are set in the daemon env AND telemetry.metrics is true in app-config.
A live smoke against us.cloud.langfuse.com on 2026-05-07 confirmed the
matching trace + generation schema is accepted (HTTP 207, both events
201 created).
* fix(langfuse): address PR #800 review feedback
P1 — Move trace forwarding off the daemon-internal run-close hook and
onto the message-persistence path. The original onTerminate hook ran
inside finish() the moment the SSE 'end' event was emitted, which is
*before* the web client's onDone handler refreshes project files and
PUTs producedFiles + final assistant content back to SQLite. Reading
SQLite at that moment routinely missed both. The fix: drop the runs.ts
hook entirely and trigger from PUT /api/projects/:id/conversations/:cid/
messages/:mid when the saved row carries a terminal runStatus. A
reportedRuns Set guards against the multiple PUT calls web makes per
turn (each retry / state update). Set entries auto-evict after the same
30 min TTL the runs map uses. Web persists a terminal-status message in
all three completion paths — onDone (succeeded), onError (failed), and
cancel (canceled) — so this catches every run shape.
P2 — postLangfuseBatch now parses the 207 Multi-Status response body.
Langfuse legacy ingestion always returns 207, and response.ok is true
for 207, so per-event validation errors used to slip through silently.
We now warn when body.errors is non-empty. Two new unit tests.
P2 — truncate() and the HARD_BATCH cap now compare UTF-8 byte length,
not String.length (which counts UTF-16 code units). A 4096-character
CJK prompt occupies 12 KB, well over the 8 KB input cap. truncate also
walks backwards to a UTF-8 leading byte so the cut never lands inside a
multi-byte codepoint. New unit test covers '设'.repeat(4096).
P2 — Spec R7 now lists the actual Langfuse trace deletion endpoint
(DELETE /api/public/traces/{traceId} for single, DELETE /api/public/traces
with body for batch). Verified by curl on us.cloud.langfuse.com:
DELETE /api/public/traces/X → 200; the path the original spec named
(POST /api/public/trace/X) returns 404. Reference link points at
langfuse.com/docs/administration/data-deletion.
P3 — Q4 (legacy ingestion vs OTel) moved from Open Questions to
Resolved Decisions. The implementation already commits to legacy and
the trade-off was discussed during design; the open-question status was
stale.
* feat(web): privacy consent surface + Settings → Privacy tab
Adds the user-facing half of the telemetry feature so the daemon-side
hook from PR #800 has something to talk to.
- AppConfig gains optional `installationId` (anonymous v4 uuid generated
on first opt-in; null after explicit decline; undefined when the user
has never seen the consent surface) and `telemetry: TelemetryConfig`
({metrics, content, artifactManifest}). syncConfigToDaemon round-trips
both fields so the bridge module sees the same prefs.
- SettingsDialog grows a Privacy section with two states. When the user
has never made a consent decision (typical first-run path), the
section renders the GDPR-aligned consent card: a kicker, the disclosure
body listing both metrics and conversation content as separate bullets,
and two equally-prominent buttons ("Share usage data" / "Don't share").
The Don't-share path keeps the app fully usable (core app must work
with all tracking declined). After a decision the same panel switches
to three independent toggles + the anonymous ID + a "Delete my data"
button that rotates the ID and turns everything off.
- App.tsx points the welcome modal at the new Privacy section so the
consent decision is the first thing a fresh installation sees.
- 17 i18n keys land in en + zh-CN + zh-TW with hand-translated copy,
and as English placeholders in the remaining 14 locales — enough for
the parity check to pass while leaving room for proper localisation
in a follow-up. Dict type updated.
- Minimal index.css for the consent card + toggle rows so the panel is
legible without depending on follow-up design polish.
Telemetry remains a no-op end-to-end until the user clicks Share usage
data: the daemon gate (prefs.metrics === true) keeps every code path
short-circuited otherwise.
* refactor(web): rebuild Privacy panel using project-native settings primitives
The first cut used custom .settings-privacy-* classes + raw HTML
checkboxes that didn't match any other Settings tab. Replace with the
shell other sections already use:
- settings-subsection containers with section-head + h4 + .hint
- seg-control / seg-btn pill toggles ("active" / "offline") for each of
the three telemetry preferences, mirroring NotificationsSection
- a 2-cell seg-control for the consent card so Share usage data and
Don't share carry identical visual weight (the GDPR equal-prominence
requirement that the previous accent / outline split missed)
- ghost button + readonly text input for the installation id row,
mirroring the API-key field pattern elsewhere
Drop the bespoke CSS block in favor of inheriting the existing
settings-section / seg-control / ghost styling. The only privacy-
specific style left is a tight definition list inside the consent card
for the metrics + content disclosure rows.
* refactor(web): use .toggle-row iOS switch for Privacy preferences
Active/offline pills (the seg-control single-cell pattern that
NotificationsSection uses) read awkwardly for a flat preference list.
Switch the three telemetry toggles to .toggle-row — the same control
NewProjectPanel uses for "speaker notes" / "animations": label + hint
on the left, iOS-style sliding switch on the right, full-row click
target. The consent card's two-button seg-control stays as-is — there
the equal-weight pill pair is exactly what GDPR equal-prominence wants.
* feat(web): standalone first-run privacy consent banner
Replaces the Settings-dialog-as-onboarding hack with a dedicated
bottom-right banner card that mounts whenever the user has never made
a privacy decision (cfg.installationId === undefined). The banner is
prominent (anchored to the corner with a soft shadow) but
non-blocking, mirrors cookie-consent UX, and shares the project's
panel styling — same .modal-elevated background, --radius-lg corners,
--shadow-lg lift.
Wiring:
- App.tsx imports PrivacyConsentModal and renders it at the root,
gated on installationId === undefined && !settingsOpen so it doesn't
double up with the Privacy tab's own consent card when Settings is
already showing.
- Share / Don't share both go through handleConfigPersist, so the
resulting installationId + telemetry prefs land in localStorage and
the daemon at the same time, reusing the existing autosave plumbing.
- The previous attempt that pinned the welcome SettingsDialog to the
Privacy section is reverted; onboarding now stays focused on agent
configuration, and the consent decision lives in its own surface.
* fix(web): keep privacy banner visible while Settings welcome modal is open
The banner gated itself on `!settingsOpen` to avoid double-rendering
with the Privacy tab's consent card. But the first-run path opens the
Settings welcome modal automatically when `onboardingCompleted=false`,
which fired immediately after bootstrap — so the banner flashed for a
moment and then vanished behind the modal backdrop.
Drop the `!settingsOpen` clause so the banner stays mounted whenever
the user has not yet made a privacy decision, and bump its z-index
above the modal backdrop (200 vs 100) so first-run users can actually
reach the consent buttons. The minor visual overlap with the Privacy
tab's own card is fine: clicking either copy resolves both surfaces.
* copy(privacy): soften consent button labels
Banner action buttons now read "Help improve Open Design" / "Not now"
(en, with hand translations in zh-CN / zh-TW and English placeholders
in the other 13 locales) instead of "Share usage data" / "Don't share".
The new wording aligns the affirmative action with the kicker copy
("Help us improve Open Design") and reads less alarming, while the
disclosure list above still names both data categories explicitly so
the consent stays informed under GDPR. The decline button stays as a
soft "Not now" rather than an aggressive "Don't share" so the reject
path doesn't read as hostile to the user.
No structural change — the two-cell seg-control still gives the buttons
identical visual weight, and the underlying side-effects are unchanged
(installationId is generated on Help / nulled on Not now, and the
telemetry prefs flip the same way).
* feat(telemetry): expand trace fields for evals & dataset construction
Each Langfuse trace now ships the full per-turn + per-install fact
sheet that the eval/dataset workflow needs, instead of only the bare
turn id + token count from before. Everything below is gated by
`prefs.metrics === true`; nothing here is content (those gates remain
separate).
Per-turn:
- model — first-class generation.model field, drives Langfuse cost
lookup and model-grouping in the UI; also mirrored in trace.metadata
and trace.tags so list-view filters work.
- reasoning — generation.modelParameters.{ reasoning } so the Model
Parameters card lights up; mirrored in metadata.
- skillId / designSystemId — metadata + tags, so dataset slices can
group by which skill/DS produced which output.
Per-process / build (constant within one daemon run, cached at start):
- appVersion / appChannel / packaged from app-version.ts
- nodeVersion (process.version), os (platform()), osRelease,
arch (os.arch())
- clientType — desktop vs web, derived from a new X-OD-Client header
the web layer sets in providers/daemon.ts (with a User-Agent sniff
fallback for third-party callers).
Plumbing:
- startChatRun stashes model / reasoning / skillId / designSystemId
on the run object alongside the existing userPrompt stash.
- POST /api/runs reads X-OD-Client and stores run.clientType.
- langfuse-bridge collects RuntimeInfo once per process and merges
per-run client carrier; ReportContext gains optional `turn` +
`runtime` blocks; existing fields stay backward compatible.
Spec gains a "Telemetry Fields Catalog" section enumerating every
field, its source, and the gate it lives under, so the eval team has a
single place to look up what's available without reading the trace
schema by example.
Tests:
- new langfuse-trace tests cover turn tags, runtime tags, generation
model/modelParameters promotion, modelParameters omission when
reasoning is unset, and metadata mirroring.
- langfuse-bridge gains an end-to-end "turn-level config" test that
threads model/reasoning/skill/DS/clientType + appVersion through
the bridge and asserts the Langfuse payload shape.
- existing tests adjusted to tolerate host-dependent os tag.
* copy(privacy): trim Share button to verb phrase only
"Help improve Open Design" overflowed the equal-width 2-cell
seg-control on the consent banner — the product name is already in
the kicker + headline above the buttons, so the button itself only
needs the verb phrase. Drop the product name from all locales:
- en: Help improve Open Design → Help improve
- zh-CN: 帮助改进 Open Design → 帮助改进
- zh-TW: 協助改進 Open Design → 協助改進
The decline button ("Not now" / "暂不" / "暫不") was already short, so
the two buttons now have comparable length and the equal-prominence
seg-control fits cleanly. Standalone Settings → Privacy panel uses
the same labels for consistency.
* fix(web): defer Settings welcome modal until privacy decision is made
Previously bootstrap raced two surfaces against each other on first
launch: the privacy consent banner (gated on installationId ===
undefined) and the Settings welcome modal (gated on
onboardingCompleted === false). The banner's higher z-index kept it
above the backdrop visually, but having two foreground surfaces at
once is still confusing UX.
Sequence them instead: bootstrap only opens the welcome modal when
the user has *already* resolved consent (installationId !== undefined).
Until then the banner owns the foreground alone. Once the user clicks
Help improve / Not now, the corresponding handler hands off to the
welcome modal if onboarding is still pending. End state matches what
it was before — just without the simultaneous-render flash.
* debug(privacy): log banner gate state to track sudden disappearance
Two console.log points to find which setCfg call (or stale bundle) is
flipping cfg.installationId from undefined to a value while the banner
is visible. To remove once the regression is reproduced.
* fix(privacy): keep installationId + telemetry out of localStorage
Daemon is now the single source of truth for the privacy decision.
Why this matters: the consent banner gates on
\`config.installationId === undefined\`, but loadConfig() merges
localStorage on top of the daemon's reply, so a stale uuid in
\`open-design:config\` (left over from a previous opt-in) was
re-hydrating the React state and immediately syncing back to the
daemon — defeating "Delete my data" and re-suppressing the banner
within milliseconds of every page load.
The deeper reason to fix it here, not just patch the gate: a privacy
identifier persisted in browser storage that the user can't see or
clear without DevTools is a compliance liability. Anything users can
revoke needs one canonical place to store it. Daemon \`app-config.json\`
already serves that role for everything else gated through
syncConfigToDaemon, so installationId + telemetry now ride that path
exclusively:
- saveConfig() strips both keys before writing localStorage.
- loadConfig() strips both keys when reading older stale payloads,
so existing installs migrate transparently on next launch.
- syncConfigToDaemon() / mergeDaemonConfig still round-trip them, so
the React state stays in sync with the daemon as before.
Net effect: clearing app-config.json (or hitting "Delete my data") now
fully resets the install identity, with no residual cohort key in
browser storage.
* feat(privacy): scrub secrets + PII from prompt/output before send
When prefs.content is on, daemon now runs the prompt and assistant
text through a regex scrubber (apps/daemon/src/redact.ts) before
posting to Langfuse. The scrubber is the simplest thing that gives
the user-facing copy a truthful claim — pure regex, zero new
dependencies, fully auditable in this Apache-2.0 repo (vs. pulling a
single-maintainer 5-month-old npm package into a core process).
Categories covered (each replaced with [REDACTED:<kind>]):
- Anthropic / OpenAI sk- keys (incl. proj/live/test/ant variants)
- Langfuse pk-lf- / sk-lf- (specific rule wins over generic sk-)
- GitHub gh[opsur]_ tokens
- AWS access key ids (AKIA + 16 uppercase)
- Google API keys (AIza + 35)
- Slack xox[abprs]- tokens
- Stripe live/test keys
- JWT header.payload.signature triples
- Bearer-header values (scheme word stays readable)
- Emails, IPv4, US-style phone numbers
- Credit cards — 13–19 digit runs that pass a Luhn check, so order ids
and unix-nanos timestamps that fail Luhn pass through unchanged
Not covered, stated openly in spec + i18n: names, postal addresses,
business-secret semantics, raw 40-hex tokens (too high a false-positive
cost for artifact slugs). Those would require an ML layer.
Wired in:
- apps/daemon/src/redact.ts — exports redactSecrets() +
redactSecretsWithCounts() helper for future audit-summary metadata.
- apps/daemon/src/langfuse-bridge.ts — runs both prompt and output
through redactSecrets() before they reach the trace builder.
- 18 unit tests cover every pattern plus negative cases (Luhn-failing
digit runs, out-of-range IPv4 octets, idempotence on re-redacted
text, ordinary prose passthrough).
- i18n privacyContentHint on en + zh-CN + zh-TW (plus 14 locale
placeholders) enumerates the categories so the consent disclosure
matches the implementation — the GDPR informed-consent requirement.
- spec gains a Pre-send Redaction subsection with the regex shape
table + intentional non-coverage list.
Drive-by: dropped the [privacy] debug logs that traced the now-fixed
bootstrap regression.
* fix(telemetry): make Langfuse reporting resilient
* feat(telemetry): nest Langfuse turn observations
* feat(telemetry): emit Langfuse tool spans
* fix(telemetry): report after finalized message writes
* fix(telemetry): honor persisted terminal status
* fix(web): let consent banner yield page clicks
* fix(telemetry): report current turn prompt only
This commit is contained in:
parent
f4eb1f1779
commit
afb331a288
47 changed files with 3806 additions and 24 deletions
|
|
@ -18,6 +18,12 @@ export interface AgentModelPrefs {
|
|||
|
||||
export type AgentCliEnvPrefs = Record<string, Record<string, string>>;
|
||||
|
||||
export interface TelemetryPrefs {
|
||||
metrics?: boolean;
|
||||
content?: boolean;
|
||||
artifactManifest?: boolean;
|
||||
}
|
||||
|
||||
export interface OrbitConfigPrefs {
|
||||
enabled: boolean;
|
||||
time: string;
|
||||
|
|
@ -33,6 +39,9 @@ export interface AppConfigPrefs {
|
|||
designSystemId?: string | null;
|
||||
disabledSkills?: string[];
|
||||
disabledDesignSystems?: string[];
|
||||
installationId?: string | null;
|
||||
telemetry?: TelemetryPrefs;
|
||||
privacyDecisionAt?: number | null;
|
||||
orbit?: OrbitConfigPrefs;
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +54,9 @@ const ALLOWED_KEYS: ReadonlySet<keyof AppConfigPrefs> = new Set([
|
|||
'designSystemId',
|
||||
'disabledSkills',
|
||||
'disabledDesignSystems',
|
||||
'installationId',
|
||||
'telemetry',
|
||||
'privacyDecisionAt',
|
||||
'orbit',
|
||||
] as const);
|
||||
|
||||
|
|
@ -54,6 +66,24 @@ function configFile(dataDir: string): string {
|
|||
|
||||
const AGENT_MODEL_KEYS: ReadonlySet<string> = new Set(['model', 'reasoning']);
|
||||
|
||||
const TELEMETRY_KEYS: ReadonlySet<string> = new Set([
|
||||
'metrics',
|
||||
'content',
|
||||
'artifactManifest',
|
||||
]);
|
||||
|
||||
function validateTelemetry(raw: unknown): TelemetryPrefs | undefined {
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
if (typeof raw !== 'object' || Array.isArray(raw)) return undefined;
|
||||
const result: Record<string, boolean> = Object.create(null);
|
||||
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (k === '__proto__' || k === 'constructor') continue;
|
||||
if (!TELEMETRY_KEYS.has(k)) continue;
|
||||
if (typeof v === 'boolean') result[k] = v;
|
||||
}
|
||||
return Object.keys(result).length > 0 ? (result as TelemetryPrefs) : undefined;
|
||||
}
|
||||
|
||||
const AGENT_CLI_ENV_KEYS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
|
||||
['claude', new Set(['CLAUDE_CONFIG_DIR', 'CLAUDE_BIN'])],
|
||||
['codex', new Set(['CODEX_HOME', 'CODEX_BIN'])],
|
||||
|
|
@ -194,6 +224,29 @@ function applyConfigValue(
|
|||
delete target[key];
|
||||
}
|
||||
}
|
||||
if (key === 'installationId') {
|
||||
if (typeof value === 'string' || value === null) target[key] = value;
|
||||
return;
|
||||
}
|
||||
if (key === 'telemetry') {
|
||||
const validated = validateTelemetry(value);
|
||||
if (validated !== undefined) {
|
||||
target[key] = validated;
|
||||
} else {
|
||||
delete target[key];
|
||||
}
|
||||
}
|
||||
if (key === 'privacyDecisionAt') {
|
||||
if (
|
||||
value === null ||
|
||||
(typeof value === 'number' && Number.isFinite(value) && value >= 0)
|
||||
) {
|
||||
target[key] = value;
|
||||
} else {
|
||||
delete target[key];
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key === 'orbit') {
|
||||
const validated = validateOrbit(value);
|
||||
if (validated !== undefined) {
|
||||
|
|
|
|||
359
apps/daemon/src/langfuse-bridge.ts
Normal file
359
apps/daemon/src/langfuse-bridge.ts
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
// Daemon ↔ langfuse-trace bridge.
|
||||
//
|
||||
// langfuse-trace.ts is dependency-free and works on a flat ReportContext.
|
||||
// This module is the glue that pulls the pieces from daemon-internal data
|
||||
// sources (the runs map, SQLite, app-config.json) into that shape and fires
|
||||
// the report. Lives here rather than inside langfuse-trace.ts so that the
|
||||
// trace module stays unit-testable without booting a database.
|
||||
//
|
||||
// See: specs/change/20260507-langfuse-telemetry/spec.md
|
||||
|
||||
import os from 'node:os';
|
||||
|
||||
import { readAppConfig } from './app-config.js';
|
||||
import type { AppVersionInfo } from './app-version.js';
|
||||
import { listMessages } from './db.js';
|
||||
import {
|
||||
reportRunCompleted,
|
||||
type ArtifactSummary,
|
||||
type EventsSummary,
|
||||
type MessageSummary,
|
||||
type ReportContext,
|
||||
type RuntimeInfo,
|
||||
type ToolCallSummary,
|
||||
type TurnInfo,
|
||||
} from './langfuse-trace.js';
|
||||
import { redactSecrets } from './redact.js';
|
||||
|
||||
interface DaemonRunRecord {
|
||||
id: string;
|
||||
projectId: string | null;
|
||||
conversationId: string | null;
|
||||
assistantMessageId: string | null;
|
||||
agentId: string | null;
|
||||
status: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
events: Array<{
|
||||
id: number;
|
||||
event: string;
|
||||
data: unknown;
|
||||
timestamp?: number;
|
||||
}>;
|
||||
// The fields below are stashed by `startChatRun` (and the POST /api/runs
|
||||
// handler) at entry time so the report path doesn't need to reach back
|
||||
// into chatBody / req across the createChatRunService boundary.
|
||||
userPrompt?: string;
|
||||
model?: string;
|
||||
reasoning?: string;
|
||||
skillId?: string;
|
||||
designSystemId?: string;
|
||||
clientType?: 'desktop' | 'web' | 'unknown';
|
||||
}
|
||||
|
||||
export interface ReportRunCompletedFromDaemonOpts {
|
||||
db: unknown;
|
||||
dataDir: string;
|
||||
run: DaemonRunRecord;
|
||||
persistedRunStatus?: string;
|
||||
persistedEndedAt?: number;
|
||||
/** App version info — collected once at daemon startup and reused. */
|
||||
appVersion?: AppVersionInfo | null;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the host/runtime info that doesn't change inside one daemon
|
||||
* process. Cheap to call repeatedly — cached at module level.
|
||||
*/
|
||||
let cachedRuntime: RuntimeInfo | undefined;
|
||||
function getRuntimeInfo(appVersion?: AppVersionInfo | null): RuntimeInfo {
|
||||
if (cachedRuntime && !appVersion) return cachedRuntime;
|
||||
const info: RuntimeInfo = {
|
||||
nodeVersion: process.version,
|
||||
os: os.platform(),
|
||||
osRelease: os.release(),
|
||||
arch: os.arch(),
|
||||
};
|
||||
if (appVersion) {
|
||||
info.appVersion = appVersion.version;
|
||||
info.appChannel = appVersion.channel;
|
||||
info.packaged = appVersion.packaged;
|
||||
}
|
||||
cachedRuntime = info;
|
||||
return info;
|
||||
}
|
||||
|
||||
function turnInfoFromRun(run: DaemonRunRecord): TurnInfo | undefined {
|
||||
const turn: TurnInfo = {};
|
||||
if (run.model) turn.model = run.model;
|
||||
if (run.reasoning) turn.reasoning = run.reasoning;
|
||||
if (run.skillId) turn.skillId = run.skillId;
|
||||
if (run.designSystemId) turn.designSystemId = run.designSystemId;
|
||||
return Object.keys(turn).length > 0 ? turn : undefined;
|
||||
}
|
||||
|
||||
function summarizeEvents(
|
||||
events: DaemonRunRecord['events'],
|
||||
durationMs: number,
|
||||
): EventsSummary {
|
||||
let toolCalls = 0;
|
||||
let errors = 0;
|
||||
for (const rec of events) {
|
||||
const data = rec.data as { type?: string } | null | undefined;
|
||||
if (rec.event === 'agent') {
|
||||
const t = data?.type;
|
||||
if (t === 'tool_use') toolCalls += 1;
|
||||
else if (t === 'error') errors += 1;
|
||||
} else if (rec.event === 'error') {
|
||||
errors += 1;
|
||||
}
|
||||
}
|
||||
return { toolCalls, errors, durationMs };
|
||||
}
|
||||
|
||||
function findUsage(
|
||||
events: DaemonRunRecord['events'],
|
||||
): MessageSummary['usage'] | undefined {
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const rec = events[i]!;
|
||||
const data = rec.data as
|
||||
| { type?: string; usage?: Record<string, unknown> | null }
|
||||
| null
|
||||
| undefined;
|
||||
if (rec.event === 'agent' && data?.type === 'usage' && data.usage) {
|
||||
const u = data.usage;
|
||||
const inputTokens =
|
||||
typeof u.input_tokens === 'number' ? u.input_tokens : undefined;
|
||||
const outputTokens =
|
||||
typeof u.output_tokens === 'number' ? u.output_tokens : undefined;
|
||||
if (inputTokens === undefined && outputTokens === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const totalTokens =
|
||||
typeof inputTokens === 'number' && typeof outputTokens === 'number'
|
||||
? inputTokens + outputTokens
|
||||
: undefined;
|
||||
const out: NonNullable<MessageSummary['usage']> = {};
|
||||
if (inputTokens !== undefined) out.inputTokens = inputTokens;
|
||||
if (outputTokens !== undefined) out.outputTokens = outputTokens;
|
||||
if (totalTokens !== undefined) out.totalTokens = totalTokens;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function eventTimestamp(
|
||||
rec: DaemonRunRecord['events'][number],
|
||||
fallback: number,
|
||||
): number {
|
||||
return typeof rec.timestamp === 'number' && Number.isFinite(rec.timestamp)
|
||||
? rec.timestamp
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function serializeToolPayload(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value === 'string') return redactSecrets(value);
|
||||
try {
|
||||
return redactSecrets(JSON.stringify(value));
|
||||
} catch {
|
||||
return redactSecrets(String(value));
|
||||
}
|
||||
}
|
||||
|
||||
function collectToolCalls(
|
||||
events: DaemonRunRecord['events'],
|
||||
runStartedAt: number,
|
||||
runEndedAt: number,
|
||||
): ToolCallSummary[] {
|
||||
const tools = new Map<string, ToolCallSummary>();
|
||||
for (const rec of events) {
|
||||
if (rec.event !== 'agent') continue;
|
||||
const data = rec.data as
|
||||
| {
|
||||
type?: string;
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
input?: unknown;
|
||||
toolUseId?: unknown;
|
||||
content?: unknown;
|
||||
isError?: unknown;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
if (data?.type === 'tool_use' && typeof data.id === 'string') {
|
||||
const timestamp = eventTimestamp(rec, runStartedAt + rec.id);
|
||||
const summary: ToolCallSummary = {
|
||||
id: data.id,
|
||||
name: typeof data.name === 'string' && data.name ? data.name : 'unknown',
|
||||
startedAt: timestamp,
|
||||
endedAt: timestamp,
|
||||
};
|
||||
const input = serializeToolPayload(data.input);
|
||||
if (input !== undefined) summary.input = input;
|
||||
tools.set(data.id, summary);
|
||||
} else if (
|
||||
data?.type === 'tool_result' &&
|
||||
typeof data.toolUseId === 'string'
|
||||
) {
|
||||
const timestamp = eventTimestamp(rec, runStartedAt + rec.id);
|
||||
const existing = tools.get(data.toolUseId);
|
||||
const summary =
|
||||
existing ??
|
||||
({
|
||||
id: data.toolUseId,
|
||||
name: 'unknown',
|
||||
startedAt: timestamp,
|
||||
endedAt: timestamp,
|
||||
} satisfies ToolCallSummary);
|
||||
summary.endedAt = Math.max(summary.startedAt, timestamp);
|
||||
const output = serializeToolPayload(data.content);
|
||||
if (output !== undefined) summary.output = output;
|
||||
summary.isError = data.isError === true;
|
||||
tools.set(data.toolUseId, summary);
|
||||
}
|
||||
}
|
||||
|
||||
return [...tools.values()].map((tool) => {
|
||||
const startedAt = Math.min(Math.max(tool.startedAt, runStartedAt), runEndedAt);
|
||||
return {
|
||||
...tool,
|
||||
startedAt,
|
||||
endedAt: Math.min(Math.max(tool.endedAt, startedAt), runEndedAt),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeProducedFiles(items: unknown): ArtifactSummary[] {
|
||||
if (!Array.isArray(items)) return [];
|
||||
const out: ArtifactSummary[] = [];
|
||||
for (const item of items) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
|
||||
const obj = item as Record<string, unknown>;
|
||||
const name = typeof obj.name === 'string' ? obj.name : '';
|
||||
const filePath = typeof obj.path === 'string' ? obj.path : '';
|
||||
const slug = filePath || name;
|
||||
if (!slug) continue;
|
||||
out.push({
|
||||
slug,
|
||||
type: typeof obj.kind === 'string' ? obj.kind : 'unknown',
|
||||
sizeBytes: typeof obj.size === 'number' ? obj.size : 0,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickRunError(
|
||||
run: DaemonRunRecord,
|
||||
status: ReportContext['run']['status'],
|
||||
): string | undefined {
|
||||
if (status === 'succeeded') return undefined;
|
||||
for (let i = run.events.length - 1; i >= 0; i -= 1) {
|
||||
const rec = run.events[i]!;
|
||||
if (rec.event === 'error') {
|
||||
const data = rec.data as
|
||||
| { error?: { message?: unknown }; message?: unknown }
|
||||
| null
|
||||
| undefined;
|
||||
const msg =
|
||||
(data?.error && typeof data.error.message === 'string'
|
||||
? data.error.message
|
||||
: undefined) ??
|
||||
(typeof data?.message === 'string' ? data.message : undefined);
|
||||
if (msg) return msg.slice(0, 1000);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeStatus(s: string): ReportContext['run']['status'] {
|
||||
if (s === 'succeeded' || s === 'failed' || s === 'canceled') return s;
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
export async function reportRunCompletedFromDaemon(
|
||||
opts: ReportRunCompletedFromDaemonOpts,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { db, dataDir, run } = opts;
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
const prefs = cfg.telemetry ?? {};
|
||||
if (prefs.metrics !== true) return;
|
||||
const installationId = cfg.installationId ?? null;
|
||||
|
||||
let messageContent = '';
|
||||
let producedFilesRaw: unknown = undefined;
|
||||
if (run.conversationId && run.assistantMessageId) {
|
||||
try {
|
||||
// Best-effort. Web persists assistant content via PUT /messages/:id
|
||||
// during the SSE stream, so by close time it is normally up to date,
|
||||
// but we tolerate a partial / missing message rather than throwing.
|
||||
const messages = (
|
||||
listMessages as (db: unknown, cid: string) => unknown[]
|
||||
)(db, run.conversationId);
|
||||
const m = (messages as Array<Record<string, unknown>>).find(
|
||||
(x) => x.id === run.assistantMessageId,
|
||||
);
|
||||
if (m) {
|
||||
messageContent = typeof m.content === 'string' ? m.content : '';
|
||||
// listMessages returns producedFiles already parsed (db.ts:965).
|
||||
producedFilesRaw = m.producedFiles;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[langfuse-bridge] message read failed:', String(err));
|
||||
}
|
||||
}
|
||||
|
||||
const startedAt = run.createdAt;
|
||||
const endedAt =
|
||||
typeof opts.persistedEndedAt === 'number' ? opts.persistedEndedAt : run.updatedAt;
|
||||
const durationMs = Math.max(0, endedAt - startedAt);
|
||||
const status = normalizeStatus(opts.persistedRunStatus ?? run.status);
|
||||
|
||||
const usage = findUsage(run.events);
|
||||
const error = pickRunError(run, status);
|
||||
const turn = turnInfoFromRun(run);
|
||||
const runtime: RuntimeInfo = {
|
||||
...getRuntimeInfo(opts.appVersion ?? null),
|
||||
...(run.clientType ? { clientType: run.clientType } : {}),
|
||||
};
|
||||
const ctx: ReportContext = {
|
||||
installationId,
|
||||
projectId: run.projectId ?? '',
|
||||
conversationId: run.conversationId ?? '',
|
||||
...(run.agentId ? { agentId: run.agentId } : {}),
|
||||
run: {
|
||||
runId: run.id,
|
||||
status,
|
||||
startedAt,
|
||||
endedAt,
|
||||
...(error ? { error } : {}),
|
||||
},
|
||||
message: {
|
||||
messageId: run.assistantMessageId ?? '',
|
||||
// Lexical scrub before send. Catches API keys / tokens / emails
|
||||
// / IPs / Luhn-valid credit cards in the prompt and assistant
|
||||
// text. See `redact.ts` for the full pattern set; the user-facing
|
||||
// privacy copy enumerates the same categories.
|
||||
prompt: redactSecrets(typeof run.userPrompt === 'string' ? run.userPrompt : ''),
|
||||
output: redactSecrets(messageContent),
|
||||
...(usage ? { usage } : {}),
|
||||
},
|
||||
artifacts: summarizeProducedFiles(producedFilesRaw),
|
||||
tools: collectToolCalls(run.events, startedAt, endedAt),
|
||||
eventsSummary: summarizeEvents(run.events, durationMs),
|
||||
prefs,
|
||||
...(turn ? { turn } : {}),
|
||||
runtime,
|
||||
};
|
||||
|
||||
await reportRunCompleted(
|
||||
ctx,
|
||||
opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {},
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[langfuse-bridge] report failed:', String(err));
|
||||
}
|
||||
}
|
||||
539
apps/daemon/src/langfuse-trace.ts
Normal file
539
apps/daemon/src/langfuse-trace.ts
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
// Langfuse trace forwarding for completed agent runs.
|
||||
//
|
||||
// This module is intentionally dependency-free (no `langfuse` SDK). It posts
|
||||
// a trace with nested observations to Langfuse's public ingestion endpoint when
|
||||
// a run reaches a terminal state. Without LANGFUSE_PUBLIC_KEY /
|
||||
// LANGFUSE_SECRET_KEY in the env, every entry point becomes a no-op so that
|
||||
// dev runs and forks of this open-source repo do not accidentally report.
|
||||
//
|
||||
// Privacy gates are layered: `prefs.metrics` is the master switch (off => no
|
||||
// network call at all), `prefs.content` decides whether the prompt /
|
||||
// assistant text is included, and `prefs.artifactManifest` decides whether
|
||||
// the produced-files manifest is included. None of these defaults to true;
|
||||
// the Web onboarding flow flips them after explicit consent.
|
||||
//
|
||||
// See: specs/change/20260507-langfuse-telemetry/spec.md
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { TelemetryPrefs } from './app-config.js';
|
||||
|
||||
// Langfuse US region: confirmed by an end-to-end smoke on 2026-05-07 — the
|
||||
// project's keys authenticate against `us.cloud.langfuse.com` only. EU host
|
||||
// (`cloud.langfuse.com`) returns 401 with the matching error message.
|
||||
// See specs/change/20260507-langfuse-telemetry/spec.md Q3.
|
||||
const DEFAULT_BASE_URL = 'https://us.cloud.langfuse.com';
|
||||
|
||||
const INPUT_MAX_BYTES = 8 * 1024;
|
||||
const OUTPUT_MAX_BYTES = 16 * 1024;
|
||||
const TOOL_INPUT_MAX_BYTES = 4 * 1024;
|
||||
const TOOL_OUTPUT_MAX_BYTES = 4 * 1024;
|
||||
const ARTIFACTS_MAX_ITEMS = 50;
|
||||
const SESSION_ID_MAX = 200; // Langfuse drops sessionIds longer than this.
|
||||
const HARD_BATCH_MAX_BYTES = 1024 * 1024;
|
||||
const DEFAULT_FETCH_TIMEOUT_MS = 20_000;
|
||||
const DEFAULT_FETCH_RETRIES = 1;
|
||||
|
||||
export interface LangfuseConfig {
|
||||
authHeader: string;
|
||||
baseUrl: string;
|
||||
timeoutMs: number;
|
||||
retries: number;
|
||||
}
|
||||
|
||||
export interface RunSummary {
|
||||
runId: string;
|
||||
status: 'succeeded' | 'failed' | 'canceled';
|
||||
startedAt: number;
|
||||
endedAt: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface MessageSummary {
|
||||
messageId: string;
|
||||
prompt: string;
|
||||
output: string;
|
||||
usage?: {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ArtifactSummary {
|
||||
slug: string;
|
||||
type: string;
|
||||
sizeBytes: number;
|
||||
sha256?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface ToolCallSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
startedAt: number;
|
||||
endedAt: number;
|
||||
input?: string;
|
||||
output?: string;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export interface EventsSummary {
|
||||
toolCalls: number;
|
||||
errors: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface RuntimeInfo {
|
||||
/** Node.js runtime version (`process.version`, e.g. 'v22.22.0'). */
|
||||
nodeVersion?: string;
|
||||
/** OS family (`os.platform()`, e.g. 'darwin' | 'win32' | 'linux'). */
|
||||
os?: string;
|
||||
/** OS kernel/release version (`os.release()`). */
|
||||
osRelease?: string;
|
||||
/** CPU architecture (`os.arch()`, e.g. 'arm64' | 'x64'). */
|
||||
arch?: string;
|
||||
/** Open Design app version reported by the daemon. */
|
||||
appVersion?: string;
|
||||
/** Build channel (development / nightly / beta / stable). */
|
||||
appChannel?: string;
|
||||
/** Whether the daemon is running inside a packaged build. */
|
||||
packaged?: boolean;
|
||||
/** Front-end carrier — `desktop` (Electron), `web` (browser), or unknown. */
|
||||
clientType?: 'desktop' | 'web' | 'unknown';
|
||||
}
|
||||
|
||||
export interface TurnInfo {
|
||||
/** Model id at the time of this turn (e.g. 'claude-sonnet-4-5'). */
|
||||
model?: string;
|
||||
/** Reasoning level / effort knob if the agent supports it. */
|
||||
reasoning?: string;
|
||||
/** Skill id selected for this turn (if any). */
|
||||
skillId?: string;
|
||||
/** Design system id selected for this turn (if any). */
|
||||
designSystemId?: string;
|
||||
}
|
||||
|
||||
export interface ReportContext {
|
||||
installationId: string | null;
|
||||
projectId: string;
|
||||
conversationId: string;
|
||||
agentId?: string;
|
||||
run: RunSummary;
|
||||
message: MessageSummary;
|
||||
artifacts: ArtifactSummary[];
|
||||
tools?: ToolCallSummary[];
|
||||
eventsSummary: EventsSummary;
|
||||
prefs: TelemetryPrefs;
|
||||
/** Per-turn config (model + skill + DS). May vary turn-to-turn within a session. */
|
||||
turn?: TurnInfo;
|
||||
/** Process- / build-level info collected once per daemon process. */
|
||||
runtime?: RuntimeInfo;
|
||||
extraTags?: string[];
|
||||
}
|
||||
|
||||
export interface ReportRunOpts {
|
||||
config?: LangfuseConfig | null;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export function readLangfuseConfig(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): LangfuseConfig | null {
|
||||
const publicKey = env.LANGFUSE_PUBLIC_KEY?.trim();
|
||||
const secretKey = env.LANGFUSE_SECRET_KEY?.trim();
|
||||
if (!publicKey || !secretKey) return null;
|
||||
const baseUrl = (env.LANGFUSE_BASE_URL?.trim() || DEFAULT_BASE_URL).replace(
|
||||
/\/+$/,
|
||||
'',
|
||||
);
|
||||
const authHeader =
|
||||
'Basic ' +
|
||||
Buffer.from(`${publicKey}:${secretKey}`, 'utf8').toString('base64');
|
||||
return {
|
||||
authHeader,
|
||||
baseUrl,
|
||||
timeoutMs: parsePositiveInt(
|
||||
env.LANGFUSE_TIMEOUT_MS,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
),
|
||||
retries: parseNonNegativeInt(env.LANGFUSE_RETRIES, DEFAULT_FETCH_RETRIES),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string | undefined, fallback: number): number {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function parseNonNegativeInt(value: string | undefined, fallback: number): number {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
// Byte-aware UTF-8 truncation. JS String.length counts UTF-16 code units,
|
||||
// not bytes — non-ASCII text (CJK, emoji) can occupy 2-4× as many bytes as
|
||||
// characters, so a `value.length > max` cap silently lets oversized prompts
|
||||
// through. We truncate on a UTF-8 byte boundary so the result is still
|
||||
// valid Unicode (no half-encoded characters).
|
||||
function truncate(value: string | undefined, maxBytes: number): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const buf = Buffer.from(value, 'utf8');
|
||||
if (buf.length <= maxBytes) return value;
|
||||
let cut = maxBytes;
|
||||
// UTF-8 continuation bytes have the bit pattern 10xxxxxx. Walk backwards
|
||||
// until we land on a leading byte (0xxxxxxx, 110xxxxx, 1110xxxx, 11110xxx)
|
||||
// so the slice doesn't end mid-character.
|
||||
while (cut > 0 && (buf[cut]! & 0xc0) === 0x80) cut -= 1;
|
||||
return buf.subarray(0, cut).toString('utf8');
|
||||
}
|
||||
|
||||
function buildTagList(ctx: ReportContext): string[] {
|
||||
const tags = ['open-design', `project:${ctx.projectId}`];
|
||||
if (ctx.agentId) tags.push(`agent:${ctx.agentId}`);
|
||||
if (ctx.turn?.model) tags.push(`model:${ctx.turn.model}`);
|
||||
if (ctx.turn?.skillId) tags.push(`skill:${ctx.turn.skillId}`);
|
||||
if (ctx.turn?.designSystemId) tags.push(`ds:${ctx.turn.designSystemId}`);
|
||||
if (ctx.runtime?.os) tags.push(`os:${ctx.runtime.os}`);
|
||||
if (ctx.runtime?.clientType && ctx.runtime.clientType !== 'unknown') {
|
||||
tags.push(`client:${ctx.runtime.clientType}`);
|
||||
}
|
||||
if (ctx.extraTags?.length) tags.push(...ctx.extraTags);
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function buildTracePayload(ctx: ReportContext): unknown[] {
|
||||
const wantsContent = ctx.prefs.content === true;
|
||||
const wantsArtifacts = ctx.prefs.artifactManifest === true;
|
||||
|
||||
const sessionId =
|
||||
ctx.conversationId.length <= SESSION_ID_MAX ? ctx.conversationId : undefined;
|
||||
|
||||
const startTimeIso = new Date(ctx.run.startedAt).toISOString();
|
||||
const endTimeIso = new Date(ctx.run.endedAt).toISOString();
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
const inputText = wantsContent
|
||||
? truncate(ctx.message.prompt, INPUT_MAX_BYTES)
|
||||
: undefined;
|
||||
const outputText = wantsContent
|
||||
? truncate(ctx.message.output, OUTPUT_MAX_BYTES)
|
||||
: undefined;
|
||||
|
||||
const artifactsList = wantsArtifacts
|
||||
? ctx.artifacts.slice(0, ARTIFACTS_MAX_ITEMS)
|
||||
: undefined;
|
||||
const artifactsTruncated =
|
||||
wantsArtifacts && ctx.artifacts.length > ARTIFACTS_MAX_ITEMS
|
||||
? true
|
||||
: undefined;
|
||||
|
||||
const tokens = ctx.message.usage
|
||||
? {
|
||||
input: ctx.message.usage.inputTokens,
|
||||
output: ctx.message.usage.outputTokens,
|
||||
total: ctx.message.usage.totalTokens,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const usage = ctx.message.usage
|
||||
? {
|
||||
input: ctx.message.usage.inputTokens,
|
||||
output: ctx.message.usage.outputTokens,
|
||||
total: ctx.message.usage.totalTokens,
|
||||
unit: 'TOKENS' as const,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const success = ctx.run.status === 'succeeded';
|
||||
const traceId = ctx.run.runId;
|
||||
const agentSpanId = `${ctx.run.runId}-agent`;
|
||||
const generationId = `${ctx.run.runId}-gen`;
|
||||
|
||||
// Trace metadata is the queryable + exportable fact-sheet for each turn.
|
||||
// Anything we want to slice on for evals or dataset construction lives
|
||||
// here. Fields are flat (Langfuse stores it as JSON but indexes shallow
|
||||
// keys best). All entries are anonymous — no PII, no credentials.
|
||||
const traceMetadata: Record<string, unknown> = {
|
||||
success,
|
||||
status: ctx.run.status,
|
||||
error: ctx.run.error ?? undefined,
|
||||
eventsSummary: ctx.eventsSummary,
|
||||
tokens,
|
||||
artifacts: artifactsList,
|
||||
artifactsTruncated,
|
||||
projectId: ctx.projectId || undefined,
|
||||
agent: ctx.agentId,
|
||||
model: ctx.turn?.model,
|
||||
reasoning: ctx.turn?.reasoning,
|
||||
skillId: ctx.turn?.skillId,
|
||||
designSystemId: ctx.turn?.designSystemId,
|
||||
appVersion: ctx.runtime?.appVersion,
|
||||
appChannel: ctx.runtime?.appChannel,
|
||||
packaged: ctx.runtime?.packaged,
|
||||
nodeVersion: ctx.runtime?.nodeVersion,
|
||||
os: ctx.runtime?.os,
|
||||
osRelease: ctx.runtime?.osRelease,
|
||||
arch: ctx.runtime?.arch,
|
||||
clientType: ctx.runtime?.clientType,
|
||||
};
|
||||
|
||||
// Generation-level model parameters mirror the Langfuse schema so the UI
|
||||
// shows them in the dedicated Model Parameters card and filters work.
|
||||
const modelParameters: Record<string, unknown> | undefined =
|
||||
ctx.turn?.reasoning ? { reasoning: ctx.turn.reasoning } : undefined;
|
||||
|
||||
const batch: unknown[] = [
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: 'trace-create',
|
||||
timestamp: nowIso,
|
||||
body: {
|
||||
id: traceId,
|
||||
name: 'open-design-turn',
|
||||
sessionId,
|
||||
userId: ctx.installationId ?? undefined,
|
||||
tags: buildTagList(ctx),
|
||||
input: inputText,
|
||||
output: outputText,
|
||||
metadata: traceMetadata,
|
||||
timestamp: startTimeIso,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: 'span-create',
|
||||
timestamp: nowIso,
|
||||
body: {
|
||||
id: agentSpanId,
|
||||
traceId,
|
||||
name: 'agent-run',
|
||||
startTime: startTimeIso,
|
||||
endTime: endTimeIso,
|
||||
input: inputText,
|
||||
output: outputText,
|
||||
level: success ? 'DEFAULT' : 'ERROR',
|
||||
statusMessage: ctx.run.error ?? undefined,
|
||||
metadata: {
|
||||
status: ctx.run.status,
|
||||
messageId: ctx.message.messageId || undefined,
|
||||
durationMs: ctx.eventsSummary.durationMs,
|
||||
toolCalls: ctx.eventsSummary.toolCalls,
|
||||
errors: ctx.eventsSummary.errors,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: 'generation-create',
|
||||
timestamp: nowIso,
|
||||
body: {
|
||||
id: generationId,
|
||||
traceId,
|
||||
parentObservationId: agentSpanId,
|
||||
name: 'llm',
|
||||
// model / modelParameters are first-class on Langfuse generations
|
||||
// (used for token-cost lookup, UI grouping, eval filters), so set
|
||||
// them at the body level instead of stuffing them into metadata.
|
||||
model: ctx.turn?.model,
|
||||
modelParameters,
|
||||
startTime: startTimeIso,
|
||||
endTime: endTimeIso,
|
||||
input: inputText,
|
||||
output: outputText,
|
||||
level: success ? 'DEFAULT' : 'ERROR',
|
||||
statusMessage: ctx.run.error ?? undefined,
|
||||
usage,
|
||||
metadata: {
|
||||
durationMs: ctx.eventsSummary.durationMs,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (ctx.tools?.length) {
|
||||
for (const tool of ctx.tools) {
|
||||
const toolSpanId = `${ctx.run.runId}-tool-${tool.id}`;
|
||||
const toolStartedAt = new Date(tool.startedAt).toISOString();
|
||||
const toolEndedAt = new Date(tool.endedAt).toISOString();
|
||||
const toolInput = wantsContent
|
||||
? truncate(tool.input, TOOL_INPUT_MAX_BYTES)
|
||||
: undefined;
|
||||
const toolOutput = wantsContent
|
||||
? truncate(tool.output, TOOL_OUTPUT_MAX_BYTES)
|
||||
: undefined;
|
||||
batch.push({
|
||||
id: randomUUID(),
|
||||
type: 'span-create',
|
||||
timestamp: nowIso,
|
||||
body: {
|
||||
id: toolSpanId,
|
||||
traceId,
|
||||
parentObservationId: agentSpanId,
|
||||
name: `tool:${tool.name}`,
|
||||
startTime: toolStartedAt,
|
||||
endTime: toolEndedAt,
|
||||
input: toolInput,
|
||||
output: toolOutput,
|
||||
level: tool.isError ? 'ERROR' : 'DEFAULT',
|
||||
metadata: {
|
||||
toolCallId: tool.id,
|
||||
toolName: tool.name,
|
||||
hasInput: tool.input !== undefined,
|
||||
hasOutput: tool.output !== undefined,
|
||||
isError: tool.isError === true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (artifactsList && (artifactsList.length > 0 || artifactsTruncated)) {
|
||||
batch.push({
|
||||
id: randomUUID(),
|
||||
type: 'event-create',
|
||||
timestamp: nowIso,
|
||||
body: {
|
||||
id: `${ctx.run.runId}-artifacts`,
|
||||
traceId,
|
||||
parentObservationId: agentSpanId,
|
||||
name: 'artifact-summary',
|
||||
startTime: endTimeIso,
|
||||
metadata: {
|
||||
artifacts: artifactsList,
|
||||
artifactsTruncated,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!success || ctx.eventsSummary.errors > 0) {
|
||||
batch.push({
|
||||
id: randomUUID(),
|
||||
type: 'event-create',
|
||||
timestamp: nowIso,
|
||||
body: {
|
||||
id: `${ctx.run.runId}-error`,
|
||||
traceId,
|
||||
parentObservationId: agentSpanId,
|
||||
name: success ? 'error-summary' : 'run-error',
|
||||
startTime: endTimeIso,
|
||||
level: 'ERROR',
|
||||
statusMessage: ctx.run.error ?? undefined,
|
||||
metadata: {
|
||||
status: ctx.run.status,
|
||||
errors: ctx.eventsSummary.errors,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return batch;
|
||||
}
|
||||
|
||||
async function postLangfuseBatch(
|
||||
config: LangfuseConfig,
|
||||
batch: unknown[],
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<void> {
|
||||
const attempts = config.retries + 1;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
const response = await fetchImpl(`${config.baseUrl}/api/public/ingestion`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: config.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(config.timeoutMs),
|
||||
body: JSON.stringify({ batch }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
if (
|
||||
attempt < attempts &&
|
||||
(response.status === 429 || response.status >= 500)
|
||||
) {
|
||||
await waitBeforeRetry(attempt);
|
||||
continue;
|
||||
}
|
||||
console.warn(
|
||||
`[langfuse-trace] Ingestion failed ${response.status}: ${body.slice(0, 200)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Langfuse legacy ingestion responds with HTTP 207 Multi-Status whose
|
||||
// body shape is `{ successes: [...], errors: [...] }`. `response.ok`
|
||||
// is true for 207, so per-event validation errors slip through unless
|
||||
// we look at the body. Surface them so a malformed payload doesn't
|
||||
// silently disappear server-side.
|
||||
const body = await response.text().catch(() => '');
|
||||
if (!body) return;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const errors =
|
||||
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as { errors?: unknown }).errors
|
||||
: undefined;
|
||||
if (Array.isArray(errors) && errors.length > 0) {
|
||||
console.warn(
|
||||
`[langfuse-trace] Per-event errors (${errors.length}): ${JSON.stringify(errors).slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt < attempts) {
|
||||
await waitBeforeRetry(attempt);
|
||||
continue;
|
||||
}
|
||||
console.warn(`[langfuse-trace] Fetch error: ${String(error)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function waitBeforeRetry(attempt: number): Promise<void> {
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(resolve, Math.min(250 * attempt, 1000)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function reportRunCompleted(
|
||||
ctx: ReportContext,
|
||||
opts: ReportRunOpts = {},
|
||||
): Promise<void> {
|
||||
if (ctx.prefs.metrics !== true) return;
|
||||
|
||||
const config =
|
||||
opts.config !== undefined ? opts.config : readLangfuseConfig();
|
||||
if (!config) return;
|
||||
|
||||
let batch: unknown[];
|
||||
try {
|
||||
batch = buildTracePayload(ctx);
|
||||
} catch (error) {
|
||||
console.warn(`[langfuse-trace] Payload build error: ${String(error)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify({ batch });
|
||||
// Compare actual UTF-8 byte length, not String.length (UTF-16 code units),
|
||||
// so the cap matches the byte-oriented contract documented in the spec
|
||||
// (and the byte-oriented limit Langfuse enforces server-side).
|
||||
const serializedBytes = Buffer.byteLength(serialized, 'utf8');
|
||||
if (serializedBytes > HARD_BATCH_MAX_BYTES) {
|
||||
console.warn(
|
||||
`[langfuse-trace] Batch too large (${serializedBytes}B > ${HARD_BATCH_MAX_BYTES}B), dropping trace ${ctx.run.runId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
||||
await postLangfuseBatch(config, batch, fetchImpl);
|
||||
}
|
||||
171
apps/daemon/src/redact.ts
Normal file
171
apps/daemon/src/redact.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
// Lexical secret / PII scrubber for telemetry payloads.
|
||||
//
|
||||
// Runs before any prompt or assistant text is sent to Langfuse. The
|
||||
// patterns here are intentionally conservative: each one matches a
|
||||
// well-defined token shape with extremely low false-positive rate (API
|
||||
// keys have a fixed prefix, JWTs have the "header.payload.signature"
|
||||
// triple, credit-card matches go through a Luhn check). What this file
|
||||
// does NOT do — and the user-facing copy must reflect that — is detect
|
||||
// names, addresses, business secrets, or anything that requires
|
||||
// understanding the meaning of the surrounding text. That's an ML / LLM
|
||||
// problem the daemon can't take on.
|
||||
//
|
||||
// Output format: every match is replaced by `[REDACTED:<kind>]` so a
|
||||
// reviewer reading a Langfuse trace can see exactly which category
|
||||
// fired without recovering the original value.
|
||||
//
|
||||
// References:
|
||||
// - Langfuse client-side masking guidance:
|
||||
// https://langfuse.com/docs/observability/features/masking
|
||||
// - GitHub token format:
|
||||
// https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-authentication-to-github
|
||||
// - AWS access key shape: 'AKIA' + 16 uppercase alphanumerics.
|
||||
// - Luhn algorithm (credit cards): https://en.wikipedia.org/wiki/Luhn_algorithm
|
||||
|
||||
interface Pattern {
|
||||
name: string;
|
||||
regex: RegExp;
|
||||
}
|
||||
|
||||
// Order matters: list specific rules before more general ones. Langfuse
|
||||
// keys (`sk-lf-...`) would otherwise be eaten by the generic `sk-...`
|
||||
// rule and labeled as a generic OpenAI-style key.
|
||||
const PATTERNS: readonly Pattern[] = [
|
||||
// Langfuse public/secret keys (pk-lf- / sk-lf-). Must run before the
|
||||
// generic sk- rule so the more specific label wins.
|
||||
{ name: 'langfuse_key', regex: /\b(?:pk|sk)-lf-[A-Za-z0-9-]{16,}\b/g },
|
||||
|
||||
// Anthropic / OpenAI-style keys: 'sk-' + optional sub-prefix + base64-ish.
|
||||
// Both vendors plus a long tail of OpenAI-compatible providers (DeepSeek,
|
||||
// MiniMax, Together, etc.) ship keys in this shape, so a single rule
|
||||
// covers most "sk-..." secrets a user might paste into a prompt.
|
||||
{ name: 'sk_key', regex: /\bsk-(?:proj-|live-|test-|ant-)?[A-Za-z0-9_-]{20,}\b/g },
|
||||
|
||||
// GitHub personal / OAuth / server / user-server / refresh tokens.
|
||||
{ name: 'github_token', regex: /\bgh[opsur]_[A-Za-z0-9]{36,251}\b/g },
|
||||
|
||||
// GitHub legacy 40-hex personal access tokens that ship with no prefix
|
||||
// are indistinguishable from a sha1 hash, so we don't try to match them
|
||||
// here — false positives would be brutal in commit logs / artifact slugs.
|
||||
|
||||
// AWS access key id. The matching secret access key is just 40 base64
|
||||
// chars with no fixed shape, so we cannot reliably redact it without
|
||||
// huge collateral damage; flagging the access key id at least
|
||||
// signals a paste happened.
|
||||
{ name: 'aws_access_key', regex: /\bAKIA[0-9A-Z]{16}\b/g },
|
||||
|
||||
// Google API keys (Firebase / Maps / etc.).
|
||||
{ name: 'google_api_key', regex: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
||||
|
||||
// Slack tokens.
|
||||
{ name: 'slack_token', regex: /\bxox[abprs]-[0-9A-Za-z-]{10,}\b/g },
|
||||
|
||||
// Stripe keys.
|
||||
{ name: 'stripe_key', regex: /\b(?:sk|pk|rk)_(?:live|test)_[0-9a-zA-Z]{16,}\b/g },
|
||||
|
||||
// JSON Web Tokens. The "header.payload.signature" triple is distinctive
|
||||
// enough that false positives are rare (the literal "eyJ" prefix is the
|
||||
// base64 encoding of '{"' which is how every JOSE header starts).
|
||||
{ name: 'jwt', regex: /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g },
|
||||
|
||||
// Bearer tokens in HTTP Authorization header copy/paste. We only match
|
||||
// the value, not the literal 'Bearer ', so the marker stays readable in
|
||||
// the redacted output ("Authorization: Bearer [REDACTED:bearer_token]").
|
||||
{ name: 'bearer_token', regex: /(?<=\bBearer\s+)[A-Za-z0-9._~+/-]{16,}={0,2}/g },
|
||||
|
||||
// Email addresses. Conservative enough to require a TLD-like trailer.
|
||||
{ name: 'email', regex: /\b[\w.+-]+@[\w-]+\.[\w.-]+\b/g },
|
||||
|
||||
// IPv4. Reject all-zero / 255.255.255.255-ish junk shapes by gating on
|
||||
// each octet being 0-255.
|
||||
{
|
||||
name: 'ipv4',
|
||||
regex: /\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\b/g,
|
||||
},
|
||||
|
||||
// Phone numbers. Tight US-leaning shape; a global PII detector would
|
||||
// need a real lib. We keep this so US-based test prompts ('call me at
|
||||
// (415) 555-...') don't ship. Note: no leading \b — '(' isn't a word
|
||||
// char, so a starting boundary would refuse to match `(415)`. We
|
||||
// require a non-digit (or start of string) before the run instead.
|
||||
{
|
||||
name: 'phone',
|
||||
regex: /(?<!\d)(?:\+?\d{1,3}[\s.-]?)?(?:\(\d{3}\)|\d{3})[\s.-]?\d{3}[\s.-]?\d{4}(?!\d)/g,
|
||||
},
|
||||
];
|
||||
|
||||
// Credit card sweep is special: a naive 13-19 digit run matches a lot of
|
||||
// non-card numbers (timestamps, IDs, hashes). We isolate the candidate
|
||||
// then run a Luhn check before redacting.
|
||||
const CARD_CANDIDATE = /\b(?:\d[ -]?){12,18}\d\b/g;
|
||||
|
||||
function isLuhnValid(digits: string): boolean {
|
||||
if (digits.length < 13 || digits.length > 19) return false;
|
||||
let sum = 0;
|
||||
let alt = false;
|
||||
for (let i = digits.length - 1; i >= 0; i -= 1) {
|
||||
let d = digits.charCodeAt(i) - 48;
|
||||
if (d < 0 || d > 9) return false;
|
||||
if (alt) {
|
||||
d *= 2;
|
||||
if (d > 9) d -= 9;
|
||||
}
|
||||
sum += d;
|
||||
alt = !alt;
|
||||
}
|
||||
return sum % 10 === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `input` with every recognised secret / PII pattern replaced by
|
||||
* a `[REDACTED:<kind>]` marker. Idempotent — re-running on already
|
||||
* redacted text only matches new tokens.
|
||||
*
|
||||
* Empty / non-string input passes through unchanged so the caller can
|
||||
* use this as a no-op on optional fields.
|
||||
*/
|
||||
export function redactSecrets(input: string): string {
|
||||
if (!input) return input;
|
||||
let out = input;
|
||||
for (const { name, regex } of PATTERNS) {
|
||||
out = out.replace(regex, `[REDACTED:${name}]`);
|
||||
}
|
||||
out = out.replace(CARD_CANDIDATE, (match) => {
|
||||
const digits = match.replace(/\D/g, '');
|
||||
return isLuhnValid(digits) ? '[REDACTED:credit_card]' : match;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as `redactSecrets` but also returns per-category counts so the
|
||||
* caller can attach an audit summary to the trace metadata
|
||||
* ('we stripped 2 keys + 1 email before send').
|
||||
*/
|
||||
export function redactSecretsWithCounts(input: string): {
|
||||
redacted: string;
|
||||
counts: Record<string, number>;
|
||||
} {
|
||||
const counts: Record<string, number> = {};
|
||||
if (!input) return { redacted: input, counts };
|
||||
let out = input;
|
||||
for (const { name, regex } of PATTERNS) {
|
||||
let matched = 0;
|
||||
out = out.replace(regex, () => {
|
||||
matched += 1;
|
||||
return `[REDACTED:${name}]`;
|
||||
});
|
||||
if (matched > 0) counts[name] = matched;
|
||||
}
|
||||
let cardCount = 0;
|
||||
out = out.replace(CARD_CANDIDATE, (match) => {
|
||||
const digits = match.replace(/\D/g, '');
|
||||
if (isLuhnValid(digits)) {
|
||||
cardCount += 1;
|
||||
return '[REDACTED:credit_card]';
|
||||
}
|
||||
return match;
|
||||
});
|
||||
if (cardCount > 0) counts.credit_card = cardCount;
|
||||
return { redacted: out, counts };
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@ export function createChatRunService({
|
|||
|
||||
const emit = (run, event, data) => {
|
||||
const id = run.nextEventId++;
|
||||
const record = { id, event, data };
|
||||
const record = { id, event, data, timestamp: Date.now() };
|
||||
run.events.push(record);
|
||||
if (run.events.length > maxEvents) run.events.splice(0, run.events.length - maxEvents);
|
||||
run.updatedAt = Date.now();
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import { subscribe as subscribeFileEvents } from './project-watchers.js';
|
|||
import { renderDesignSystemPreview } from './design-system-preview.js';
|
||||
import { renderDesignSystemShowcase } from './design-system-showcase.js';
|
||||
import { createChatRunService } from './runs.js';
|
||||
import { reportRunCompletedFromDaemon } from './langfuse-bridge.js';
|
||||
import {
|
||||
redactSecrets,
|
||||
testAgentConnection,
|
||||
|
|
@ -1056,6 +1057,22 @@ function sendApiError(res, status, code, message, init = {}) {
|
|||
.json(createCompatApiErrorResponse(code, message, init));
|
||||
}
|
||||
|
||||
const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'canceled']);
|
||||
|
||||
export function shouldReportRunCompletedFromMessage(saved, body = {}) {
|
||||
return Boolean(
|
||||
saved &&
|
||||
saved.runId &&
|
||||
typeof saved.runStatus === 'string' &&
|
||||
TERMINAL_RUN_STATUSES.has(saved.runStatus) &&
|
||||
body?.telemetryFinalized === true,
|
||||
);
|
||||
}
|
||||
|
||||
export function telemetryPromptFromRunRequest(message, currentPrompt) {
|
||||
return typeof currentPrompt === 'string' ? currentPrompt : message;
|
||||
}
|
||||
|
||||
const CLOUDFLARE_PAGES_PROJECT_METADATA_KEY = 'cloudflarePagesProjectName';
|
||||
|
||||
function cloudflarePagesDeploymentMetadata(projectName) {
|
||||
|
|
@ -2809,6 +2826,31 @@ export async function startServer({
|
|||
});
|
||||
// Bump the parent project's updatedAt so the project list re-orders.
|
||||
updateProject(db, req.params.id, {});
|
||||
// Forward to Langfuse only on the explicit final message write. The web
|
||||
// stream can persist a terminal runStatus before onDone has flushed the
|
||||
// final assistant content and produced-file manifest; telemetryFinalized
|
||||
// marks the later PUT that is safe for the bridge's SQLite read.
|
||||
if (
|
||||
shouldReportRunCompletedFromMessage(saved, m) &&
|
||||
!reportedRuns.has(saved.runId)
|
||||
) {
|
||||
const run = design.runs.get(saved.runId);
|
||||
if (run) {
|
||||
reportedRuns.add(saved.runId);
|
||||
// Auto-evict so the Set doesn't accumulate forever in long-running
|
||||
// daemons. Same TTL as the runs map cleanup in runs.ts.
|
||||
setTimeout(() => reportedRuns.delete(saved.runId), 30 * 60 * 1000).unref?.();
|
||||
void reportRunCompletedFromDaemon({
|
||||
db,
|
||||
dataDir: RUNTIME_DATA_DIR,
|
||||
run,
|
||||
persistedRunStatus: saved.runStatus,
|
||||
persistedEndedAt:
|
||||
typeof saved.endedAt === 'number' ? saved.endedAt : undefined,
|
||||
appVersion: cachedAppVersion,
|
||||
});
|
||||
}
|
||||
}
|
||||
res.json({ message: saved });
|
||||
});
|
||||
|
||||
|
|
@ -4741,6 +4783,26 @@ export async function startServer({
|
|||
runs: createChatRunService({ createSseResponse, createSseErrorPayload }),
|
||||
};
|
||||
|
||||
// Tracks runs whose completion has already been forwarded to Langfuse so
|
||||
// that repeated PUT /messages/:id calls (web buffers + retries) only emit
|
||||
// one trace per run. Entries are scrubbed when the run's TTL window
|
||||
// expires (30 min, mirrors runs.ts).
|
||||
const reportedRuns = new Set();
|
||||
|
||||
// App-version snapshot read once at server start. Used as static metadata
|
||||
// on every Langfuse trace so we can correlate behaviour with releases
|
||||
// without paying the package.json read cost per turn. Updates require a
|
||||
// daemon restart, which is fine — version doesn't change in-process.
|
||||
let cachedAppVersion = null;
|
||||
void (async () => {
|
||||
try {
|
||||
cachedAppVersion = await readCurrentAppVersionInfo();
|
||||
} catch {
|
||||
// Telemetry is best-effort; running with appVersion === null just
|
||||
// omits the field from the trace.
|
||||
}
|
||||
})();
|
||||
|
||||
const composeDaemonSystemPrompt = async ({
|
||||
agentId,
|
||||
projectId,
|
||||
|
|
@ -4886,6 +4948,7 @@ export async function startServer({
|
|||
const {
|
||||
agentId,
|
||||
message,
|
||||
currentPrompt,
|
||||
systemPrompt,
|
||||
imagePaths = [],
|
||||
projectId,
|
||||
|
|
@ -4908,6 +4971,17 @@ export async function startServer({
|
|||
if (typeof clientRequestId === 'string' && clientRequestId)
|
||||
run.clientRequestId = clientRequestId;
|
||||
if (typeof agentId === 'string' && agentId) run.agentId = agentId;
|
||||
// Stash the original user prompt + per-turn config so the
|
||||
// langfuse-bridge report path can include them without reaching back
|
||||
// into chatBody across the createChatRunService boundary. Each field
|
||||
// is optional and only set when the chat body actually carried it.
|
||||
const telemetryPrompt = telemetryPromptFromRunRequest(message, currentPrompt);
|
||||
if (typeof telemetryPrompt === 'string') run.userPrompt = telemetryPrompt;
|
||||
if (typeof model === 'string' && model) run.model = model;
|
||||
if (typeof reasoning === 'string' && reasoning) run.reasoning = reasoning;
|
||||
if (typeof skillId === 'string' && skillId) run.skillId = skillId;
|
||||
if (typeof designSystemId === 'string' && designSystemId)
|
||||
run.designSystemId = designSystemId;
|
||||
const def = getAgentDef(agentId);
|
||||
if (!def)
|
||||
return design.runs.fail(
|
||||
|
|
@ -6021,6 +6095,16 @@ export async function startServer({
|
|||
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
|
||||
}
|
||||
const run = design.runs.create(req.body || {});
|
||||
// Capture which front-end carrier started the run (Electron desktop
|
||||
// shell vs. plain browser). Web sets this header explicitly; falls
|
||||
// back to a UA sniff if header is absent. Used as a telemetry tag.
|
||||
const declared = String(req.get('x-od-client') ?? '').toLowerCase();
|
||||
if (declared === 'desktop' || declared === 'web') {
|
||||
run.clientType = declared;
|
||||
} else {
|
||||
const ua = String(req.get('user-agent') ?? '');
|
||||
run.clientType = ua.includes('Electron/') ? 'desktop' : 'web';
|
||||
}
|
||||
/** @type {import('@open-design/contracts').ChatRunCreateResponse} */
|
||||
const body = { runId: run.id };
|
||||
res.status(202).json(body);
|
||||
|
|
|
|||
|
|
@ -411,6 +411,136 @@ describe('app-config disabled lists', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('app-config telemetry prefs', () => {
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dataDir = await mkdtemp(path.join(tmpdir(), 'od-telemetry-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('persists installationId as string', async () => {
|
||||
await writeAppConfig(dataDir, {
|
||||
installationId: '11111111-2222-3333-4444-555555555555',
|
||||
});
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.installationId).toBe('11111111-2222-3333-4444-555555555555');
|
||||
});
|
||||
|
||||
it('clears installationId when null is sent', async () => {
|
||||
await writeAppConfig(dataDir, { installationId: 'abc' });
|
||||
await writeAppConfig(dataDir, { installationId: null });
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.installationId).toBeNull();
|
||||
});
|
||||
|
||||
it('drops installationId of wrong type', async () => {
|
||||
await writeAppConfig(dataDir, { installationId: 12345 } as any);
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.installationId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('persists privacyDecisionAt as a timestamp', async () => {
|
||||
await writeAppConfig(dataDir, { privacyDecisionAt: 1778244000000 });
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.privacyDecisionAt).toBe(1778244000000);
|
||||
});
|
||||
|
||||
it('clears privacyDecisionAt when null is sent', async () => {
|
||||
await writeAppConfig(dataDir, { privacyDecisionAt: 1778244000000 });
|
||||
await writeAppConfig(dataDir, { privacyDecisionAt: null });
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.privacyDecisionAt).toBeNull();
|
||||
});
|
||||
|
||||
it('drops privacyDecisionAt of wrong type', async () => {
|
||||
await writeAppConfig(dataDir, { privacyDecisionAt: 'yesterday' } as any);
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.privacyDecisionAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('persists full telemetry prefs', async () => {
|
||||
await writeAppConfig(dataDir, {
|
||||
telemetry: { metrics: true, content: true, artifactManifest: false },
|
||||
});
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.telemetry).toEqual({
|
||||
metrics: true,
|
||||
content: true,
|
||||
artifactManifest: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('persists partial telemetry prefs and omits absent keys', async () => {
|
||||
await writeAppConfig(dataDir, { telemetry: { metrics: true } });
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.telemetry).toEqual({ metrics: true });
|
||||
});
|
||||
|
||||
it('drops telemetry inner values that are not booleans', async () => {
|
||||
await writeAppConfig(dataDir, {
|
||||
telemetry: {
|
||||
metrics: 'yes' as any,
|
||||
content: 1 as any,
|
||||
artifactManifest: true,
|
||||
},
|
||||
} as any);
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.telemetry).toEqual({ artifactManifest: true });
|
||||
});
|
||||
|
||||
it('drops telemetry entirely when no inner key is valid', async () => {
|
||||
await writeAppConfig(dataDir, {
|
||||
onboardingCompleted: true,
|
||||
telemetry: { metrics: 'yes' } as any,
|
||||
} as any);
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.onboardingCompleted).toBe(true);
|
||||
expect(cfg.telemetry).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops unknown keys nested inside telemetry', async () => {
|
||||
await writeAppConfig(dataDir, {
|
||||
telemetry: { metrics: true, rogue: true } as any,
|
||||
} as any);
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.telemetry).toEqual({ metrics: true });
|
||||
expect(cfg.telemetry).not.toHaveProperty('rogue');
|
||||
});
|
||||
|
||||
it('drops telemetry when value is not a plain object', async () => {
|
||||
await writeAppConfig(dataDir, { telemetry: [true] } as any);
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.telemetry).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears telemetry when null is sent', async () => {
|
||||
await writeAppConfig(dataDir, {
|
||||
telemetry: { metrics: true, content: true },
|
||||
});
|
||||
await writeAppConfig(dataDir, { telemetry: null } as any);
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.telemetry).toBeUndefined();
|
||||
});
|
||||
|
||||
it('merges telemetry without disturbing other keys', async () => {
|
||||
await writeAppConfig(dataDir, {
|
||||
installationId: 'install-1',
|
||||
telemetry: { metrics: true },
|
||||
agentId: 'claude',
|
||||
});
|
||||
await writeAppConfig(dataDir, { telemetry: { content: true } });
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
expect(cfg.installationId).toBe('install-1');
|
||||
expect(cfg.agentId).toBe('claude');
|
||||
// telemetry is replaced (not deep-merged) — matches the agentModels semantics.
|
||||
expect(cfg.telemetry).toEqual({ content: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('app-config origin guard', () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
|
|
|
|||
540
apps/daemon/tests/langfuse-bridge.test.ts
Normal file
540
apps/daemon/tests/langfuse-bridge.test.ts
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { reportRunCompletedFromDaemon } from '../src/langfuse-bridge.js';
|
||||
|
||||
interface FakeMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
producedFiles?: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function makeDb(messagesByConvo: Record<string, FakeMessage[]> = {}) {
|
||||
return {
|
||||
__messages: messagesByConvo,
|
||||
prepare() {
|
||||
throw new Error('listMessages should be the only DB call in tests');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeRun(over: Partial<Parameters<typeof reportRunCompletedFromDaemon>[0]['run']> = {}) {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: 'run-id-1',
|
||||
projectId: 'proj-1',
|
||||
conversationId: 'conv-1',
|
||||
assistantMessageId: 'msg-1',
|
||||
agentId: 'claude',
|
||||
status: 'succeeded',
|
||||
createdAt: now - 4500,
|
||||
updatedAt: now,
|
||||
events: [
|
||||
{
|
||||
id: 1,
|
||||
event: 'agent',
|
||||
timestamp: now - 4000,
|
||||
data: {
|
||||
type: 'tool_use',
|
||||
id: 'tool-1',
|
||||
name: 'Bash',
|
||||
input: { command: 'ls -la' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
event: 'agent',
|
||||
timestamp: now - 3500,
|
||||
data: {
|
||||
type: 'tool_result',
|
||||
toolUseId: 'tool-1',
|
||||
content: 'total 0',
|
||||
isError: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
event: 'agent',
|
||||
timestamp: now - 3000,
|
||||
data: {
|
||||
type: 'tool_use',
|
||||
id: 'tool-2',
|
||||
name: 'Write',
|
||||
input: { path: 'index.html' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
event: 'agent',
|
||||
timestamp: now - 2500,
|
||||
data: {
|
||||
type: 'tool_result',
|
||||
toolUseId: 'tool-2',
|
||||
content: 'wrote index.html',
|
||||
isError: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
event: 'agent',
|
||||
timestamp: now - 2000,
|
||||
data: {
|
||||
type: 'usage',
|
||||
usage: { input_tokens: 100, output_tokens: 200 },
|
||||
},
|
||||
},
|
||||
] as Array<{ id: number; event: string; data: unknown; timestamp?: number }>,
|
||||
userPrompt: 'design a coffee landing page',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function bodyOf(
|
||||
batch: unknown[],
|
||||
type: string,
|
||||
name?: string,
|
||||
): Record<string, any> {
|
||||
const event = (batch as Array<{ type: string; body: Record<string, any> }>).find(
|
||||
(item) => item.type === type && (name === undefined || item.body.name === name),
|
||||
);
|
||||
expect(event).toBeTruthy();
|
||||
return event!.body;
|
||||
}
|
||||
|
||||
describe('langfuse-bridge.reportRunCompletedFromDaemon', () => {
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dataDir = await mkdtemp(path.join(tmpdir(), 'od-bridge-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function writeAppCfg(cfg: Record<string, unknown>) {
|
||||
await writeFile(path.join(dataDir, 'app-config.json'), JSON.stringify(cfg));
|
||||
}
|
||||
|
||||
it('does nothing when telemetry.metrics is off', async () => {
|
||||
await writeAppCfg({
|
||||
installationId: 'install-1',
|
||||
telemetry: { metrics: false },
|
||||
});
|
||||
const fetchSpy = vi.fn();
|
||||
await reportRunCompletedFromDaemon({
|
||||
db: makeDb(),
|
||||
dataDir,
|
||||
run: makeRun() as any,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when no app-config.json exists (fresh install)', async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
await reportRunCompletedFromDaemon({
|
||||
db: makeDb(),
|
||||
dataDir,
|
||||
run: makeRun() as any,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('builds a ReportContext from db + app-config and POSTs the trace', async () => {
|
||||
await writeAppCfg({
|
||||
installationId: 'install-uuid-1',
|
||||
telemetry: { metrics: true, content: true, artifactManifest: true },
|
||||
});
|
||||
const messages: FakeMessage[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: 'Here is a draft …',
|
||||
producedFiles: [
|
||||
{ name: 'index.html', kind: 'html', size: 4096 },
|
||||
{ name: 'style.css', kind: 'code', size: 800 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response('{}', { status: 207 }));
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk';
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk';
|
||||
try {
|
||||
await reportRunCompletedFromDaemon({
|
||||
db: makeDbWithListMessages({ 'conv-1': messages }),
|
||||
dataDir,
|
||||
run: makeRun() as any,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
} finally {
|
||||
delete process.env.LANGFUSE_PUBLIC_KEY;
|
||||
delete process.env.LANGFUSE_SECRET_KEY;
|
||||
}
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
|
||||
const batch = JSON.parse(init.body as string).batch as any[];
|
||||
expect(batch.map((item) => item.type)).toEqual([
|
||||
'trace-create',
|
||||
'span-create',
|
||||
'generation-create',
|
||||
'span-create',
|
||||
'span-create',
|
||||
'event-create',
|
||||
]);
|
||||
const trace = batch[0].body;
|
||||
const span = bodyOf(batch, 'span-create', 'agent-run');
|
||||
const generation = bodyOf(batch, 'generation-create', 'llm');
|
||||
const bash = bodyOf(batch, 'span-create', 'tool:Bash');
|
||||
const write = bodyOf(batch, 'span-create', 'tool:Write');
|
||||
const artifacts = bodyOf(batch, 'event-create', 'artifact-summary');
|
||||
expect(trace.userId).toBe('install-uuid-1');
|
||||
expect(trace.sessionId).toBe('conv-1');
|
||||
expect(trace.input).toBe('design a coffee landing page');
|
||||
expect(trace.output).toBe('Here is a draft …');
|
||||
expect(span.id).toBe('run-id-1-agent');
|
||||
expect(span.traceId).toBe('run-id-1');
|
||||
expect(span.input).toBe('design a coffee landing page');
|
||||
expect(span.output).toBe('Here is a draft …');
|
||||
expect(generation.parentObservationId).toBe('run-id-1-agent');
|
||||
expect(bash.parentObservationId).toBe('run-id-1-agent');
|
||||
expect(bash.input).toMatch(/ls -la/);
|
||||
expect(bash.output).toBe('total 0');
|
||||
expect(write.parentObservationId).toBe('run-id-1-agent');
|
||||
expect(write.metadata.toolName).toBe('Write');
|
||||
expect(artifacts.parentObservationId).toBe('run-id-1-agent');
|
||||
expect(artifacts.metadata.artifacts).toEqual([
|
||||
{ slug: 'index.html', type: 'html', sizeBytes: 4096 },
|
||||
{ slug: 'style.css', type: 'code', sizeBytes: 800 },
|
||||
]);
|
||||
// Core tags must be present. The bridge also tacks on an `os:<...>`
|
||||
// tag derived from the host (`darwin` / `linux` / `win32`), which is
|
||||
// useful telemetry but varies between dev / CI environments — assert
|
||||
// its presence by prefix rather than pinning a value.
|
||||
expect(trace.tags).toEqual(
|
||||
expect.arrayContaining(['open-design', 'project:proj-1', 'agent:claude']),
|
||||
);
|
||||
expect((trace.tags as string[]).some((t) => t.startsWith('os:'))).toBe(true);
|
||||
expect(trace.metadata.eventsSummary.toolCalls).toBe(2);
|
||||
expect(trace.metadata.eventsSummary.errors).toBe(0);
|
||||
expect(trace.metadata.tokens).toEqual({
|
||||
input: 100,
|
||||
output: 200,
|
||||
total: 300,
|
||||
});
|
||||
expect(trace.metadata.artifacts).toEqual([
|
||||
{ slug: 'index.html', type: 'html', sizeBytes: 4096 },
|
||||
{ slug: 'style.css', type: 'code', sizeBytes: 800 },
|
||||
]);
|
||||
expect(trace.metadata.success).toBe(true);
|
||||
});
|
||||
|
||||
it('attaches turn-level config (model / reasoning / skill / DS) to trace + generation', async () => {
|
||||
await writeAppCfg({
|
||||
installationId: 'install-uuid-1',
|
||||
telemetry: { metrics: true, content: false, artifactManifest: false },
|
||||
});
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response('{}', { status: 207 }));
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk';
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk';
|
||||
try {
|
||||
await reportRunCompletedFromDaemon({
|
||||
db: makeDbWithListMessages({ 'conv-1': [] }),
|
||||
dataDir,
|
||||
run: makeRun({
|
||||
model: 'claude-sonnet-4-5',
|
||||
reasoning: 'high',
|
||||
skillId: 'landing-page',
|
||||
designSystemId: 'mission-control',
|
||||
clientType: 'desktop',
|
||||
}) as any,
|
||||
appVersion: {
|
||||
version: '0.5.0',
|
||||
channel: 'beta',
|
||||
packaged: true,
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
},
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
} finally {
|
||||
delete process.env.LANGFUSE_PUBLIC_KEY;
|
||||
delete process.env.LANGFUSE_SECRET_KEY;
|
||||
}
|
||||
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
|
||||
const batch = JSON.parse(init.body as string).batch as any[];
|
||||
const trace = batch[0].body;
|
||||
const generation = bodyOf(batch, 'generation-create', 'llm');
|
||||
|
||||
// Turn-level: trace metadata + tags carry it for filtering / grouping.
|
||||
expect(trace.metadata.model).toBe('claude-sonnet-4-5');
|
||||
expect(trace.metadata.reasoning).toBe('high');
|
||||
expect(trace.metadata.skillId).toBe('landing-page');
|
||||
expect(trace.metadata.designSystemId).toBe('mission-control');
|
||||
expect(trace.tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
'model:claude-sonnet-4-5',
|
||||
'skill:landing-page',
|
||||
'ds:mission-control',
|
||||
'client:desktop',
|
||||
]),
|
||||
);
|
||||
|
||||
// Runtime / build info on every trace.
|
||||
expect(trace.metadata.appVersion).toBe('0.5.0');
|
||||
expect(trace.metadata.appChannel).toBe('beta');
|
||||
expect(trace.metadata.packaged).toBe(true);
|
||||
expect(trace.metadata.clientType).toBe('desktop');
|
||||
expect(typeof trace.metadata.os).toBe('string');
|
||||
expect(typeof trace.metadata.nodeVersion).toBe('string');
|
||||
|
||||
// Generation: model is a first-class Langfuse field (not just metadata),
|
||||
// and reasoning lands on modelParameters where Langfuse expects it.
|
||||
expect(generation.model).toBe('claude-sonnet-4-5');
|
||||
expect(generation.modelParameters).toEqual({ reasoning: 'high' });
|
||||
});
|
||||
|
||||
it('omits content + artifacts when those gates are off', async () => {
|
||||
await writeAppCfg({
|
||||
installationId: 'install-1',
|
||||
telemetry: {
|
||||
metrics: true,
|
||||
content: false,
|
||||
artifactManifest: false,
|
||||
},
|
||||
});
|
||||
const messages: FakeMessage[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: 'sensitive output',
|
||||
producedFiles: [{ name: 'secret.html', kind: 'html', size: 1 }],
|
||||
},
|
||||
];
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response('{}', { status: 207 }));
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk';
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk';
|
||||
try {
|
||||
await reportRunCompletedFromDaemon({
|
||||
db: makeDbWithListMessages({ 'conv-1': messages }),
|
||||
dataDir,
|
||||
run: makeRun() as any,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
} finally {
|
||||
delete process.env.LANGFUSE_PUBLIC_KEY;
|
||||
delete process.env.LANGFUSE_SECRET_KEY;
|
||||
}
|
||||
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
|
||||
const trace = JSON.parse(init.body as string).batch[0].body;
|
||||
expect(trace.input).toBeUndefined();
|
||||
expect(trace.output).toBeUndefined();
|
||||
expect(trace.metadata.artifacts).toBeUndefined();
|
||||
// tokens + eventsSummary are still in metadata since they're metrics
|
||||
expect(trace.metadata.tokens).toEqual({
|
||||
input: 100,
|
||||
output: 200,
|
||||
total: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports only the current user turn captured on the run', async () => {
|
||||
await writeAppCfg({
|
||||
installationId: 'install-1',
|
||||
telemetry: { metrics: true, content: true },
|
||||
});
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response('{}', { status: 207 }));
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk';
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk';
|
||||
try {
|
||||
await reportRunCompletedFromDaemon({
|
||||
db: makeDbWithListMessages({
|
||||
'conv-1': [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: 'latest answer',
|
||||
},
|
||||
],
|
||||
}),
|
||||
dataDir,
|
||||
run: makeRun({ userPrompt: 'post-consent revision' }) as any,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
} finally {
|
||||
delete process.env.LANGFUSE_PUBLIC_KEY;
|
||||
delete process.env.LANGFUSE_SECRET_KEY;
|
||||
}
|
||||
|
||||
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
|
||||
const payload = init.body as string;
|
||||
const batch = JSON.parse(payload).batch as any[];
|
||||
expect(batch[0].body.input).toBe('post-consent revision');
|
||||
expect(bodyOf(batch, 'span-create', 'agent-run').input).toBe(
|
||||
'post-consent revision',
|
||||
);
|
||||
expect(payload).not.toContain('pre-consent brief');
|
||||
});
|
||||
|
||||
it('passes status=failed and a clipped error message through', async () => {
|
||||
await writeAppCfg({
|
||||
installationId: 'install-1',
|
||||
telemetry: { metrics: true },
|
||||
});
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response('{}', { status: 207 }));
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk';
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk';
|
||||
try {
|
||||
await reportRunCompletedFromDaemon({
|
||||
db: makeDbWithListMessages({ 'conv-1': [] }),
|
||||
dataDir,
|
||||
run: makeRun({
|
||||
status: 'failed',
|
||||
events: [
|
||||
{
|
||||
id: 1,
|
||||
event: 'error',
|
||||
data: { error: { message: 'agent stream blew up' } },
|
||||
},
|
||||
],
|
||||
}) as any,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
} finally {
|
||||
delete process.env.LANGFUSE_PUBLIC_KEY;
|
||||
delete process.env.LANGFUSE_SECRET_KEY;
|
||||
}
|
||||
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
|
||||
const batch = JSON.parse(init.body as string).batch as any[];
|
||||
expect(batch[0].body.metadata.status).toBe('failed');
|
||||
expect(batch[0].body.metadata.success).toBe(false);
|
||||
expect(batch[0].body.metadata.error).toBe('agent stream blew up');
|
||||
expect(bodyOf(batch, 'span-create', 'agent-run').level).toBe('ERROR');
|
||||
expect(bodyOf(batch, 'generation-create', 'llm').level).toBe('ERROR');
|
||||
expect(bodyOf(batch, 'generation-create', 'llm').statusMessage).toBe(
|
||||
'agent stream blew up',
|
||||
);
|
||||
expect(bodyOf(batch, 'event-create', 'run-error').statusMessage).toBe(
|
||||
'agent stream blew up',
|
||||
);
|
||||
});
|
||||
|
||||
it('survives a missing assistant message (web has not PUT yet)', async () => {
|
||||
await writeAppCfg({
|
||||
installationId: 'install-1',
|
||||
telemetry: { metrics: true, content: true },
|
||||
});
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response('{}', { status: 207 }));
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk';
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk';
|
||||
try {
|
||||
await reportRunCompletedFromDaemon({
|
||||
db: makeDbWithListMessages({ 'conv-1': [] }),
|
||||
dataDir,
|
||||
run: makeRun() as any,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
} finally {
|
||||
delete process.env.LANGFUSE_PUBLIC_KEY;
|
||||
delete process.env.LANGFUSE_SECRET_KEY;
|
||||
}
|
||||
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
|
||||
const trace = JSON.parse(init.body as string).batch[0].body;
|
||||
expect(trace.input).toBe('design a coffee landing page');
|
||||
// truncate() drops empty strings, so output is omitted entirely.
|
||||
expect(trace.output).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the persisted terminal status when the in-memory run has not settled yet', async () => {
|
||||
await writeAppCfg({
|
||||
installationId: 'install-uuid-1',
|
||||
telemetry: { metrics: true, content: false, artifactManifest: false },
|
||||
});
|
||||
const run = makeRun({
|
||||
status: 'cancelRequested',
|
||||
updatedAt: 1_700_000_009_000,
|
||||
});
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response('{}', { status: 207 }));
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk';
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk';
|
||||
try {
|
||||
await reportRunCompletedFromDaemon({
|
||||
db: makeDbWithListMessages({ 'conv-1': [] }),
|
||||
dataDir,
|
||||
run: run as any,
|
||||
persistedRunStatus: 'canceled',
|
||||
persistedEndedAt: run.createdAt + 2500,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
} finally {
|
||||
delete process.env.LANGFUSE_PUBLIC_KEY;
|
||||
delete process.env.LANGFUSE_SECRET_KEY;
|
||||
}
|
||||
|
||||
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
|
||||
const batch = JSON.parse(init.body as string).batch as any[];
|
||||
const trace = batch[0].body;
|
||||
const span = bodyOf(batch, 'span-create', 'agent-run');
|
||||
expect(trace.metadata.status).toBe('canceled');
|
||||
expect(trace.metadata.eventsSummary.durationMs).toBe(2500);
|
||||
expect(span.metadata.status).toBe('canceled');
|
||||
expect(span.endTime).toBe(new Date(run.createdAt + 2500).toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
// listMessages reads from a `prepare(...).all(cid)` call against
|
||||
// better-sqlite3. To avoid spinning up SQLite in unit tests we provide a
|
||||
// stub that satisfies the same shape used in `apps/daemon/src/db.ts`.
|
||||
function makeDbWithListMessages(messagesByConvo: Record<string, FakeMessage[]>) {
|
||||
// Mirror db.ts: SELECT returns *Json columns and listMessages runs them
|
||||
// through normalizeMessage which JSON.parses producedFilesJson into
|
||||
// producedFiles. Tests pass producedFiles directly, so we round-trip
|
||||
// through JSON.stringify to match the real-world shape.
|
||||
return {
|
||||
prepare(_sql: string) {
|
||||
return {
|
||||
all(cid: string) {
|
||||
return (messagesByConvo[cid] ?? []).map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
agentId: null,
|
||||
agentName: null,
|
||||
runId: null,
|
||||
runStatus: null,
|
||||
lastRunEventId: null,
|
||||
eventsJson: null,
|
||||
attachmentsJson: null,
|
||||
commentAttachmentsJson: null,
|
||||
producedFilesJson: m.producedFiles
|
||||
? JSON.stringify(m.producedFiles)
|
||||
: null,
|
||||
createdAt: 0,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
position: 0,
|
||||
}));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
602
apps/daemon/tests/langfuse-trace.test.ts
Normal file
602
apps/daemon/tests/langfuse-trace.test.ts
Normal file
|
|
@ -0,0 +1,602 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildTracePayload,
|
||||
readLangfuseConfig,
|
||||
reportRunCompleted,
|
||||
type LangfuseConfig,
|
||||
type ReportContext,
|
||||
} from '../src/langfuse-trace.js';
|
||||
|
||||
function makeCtx(overrides: Partial<ReportContext> = {}): ReportContext {
|
||||
const base: ReportContext = {
|
||||
installationId: 'install-uuid-1',
|
||||
projectId: 'proj-1',
|
||||
conversationId: 'conv-uuid-aaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||||
agentId: 'claude',
|
||||
run: {
|
||||
runId: 'run-1',
|
||||
status: 'succeeded',
|
||||
startedAt: 1_700_000_000_000,
|
||||
endedAt: 1_700_000_004_500,
|
||||
},
|
||||
message: {
|
||||
messageId: 'msg-1',
|
||||
prompt: 'Make a landing page for a coffee shop.',
|
||||
output: 'Here is a landing page draft …',
|
||||
usage: {
|
||||
inputTokens: 1234,
|
||||
outputTokens: 567,
|
||||
totalTokens: 1801,
|
||||
},
|
||||
},
|
||||
artifacts: [],
|
||||
tools: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'Bash',
|
||||
startedAt: 1_700_000_001_000,
|
||||
endedAt: 1_700_000_001_800,
|
||||
input: '{"command":"ls -la"}',
|
||||
output: 'total 0',
|
||||
},
|
||||
{
|
||||
id: 'tool-2',
|
||||
name: 'Write',
|
||||
startedAt: 1_700_000_002_000,
|
||||
endedAt: 1_700_000_002_900,
|
||||
input: '{"path":"index.html"}',
|
||||
output: 'wrote index.html',
|
||||
},
|
||||
],
|
||||
eventsSummary: { toolCalls: 2, errors: 0, durationMs: 4500 },
|
||||
prefs: { metrics: true, content: false, artifactManifest: false },
|
||||
};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
const TEST_CONFIG: LangfuseConfig = {
|
||||
authHeader: 'Basic dGVzdA==',
|
||||
baseUrl: 'https://us.cloud.langfuse.com',
|
||||
timeoutMs: 20_000,
|
||||
retries: 0,
|
||||
};
|
||||
|
||||
function bodyOf(
|
||||
batch: unknown[],
|
||||
type: string,
|
||||
name?: string,
|
||||
): Record<string, any> {
|
||||
const event = (batch as Array<{ type: string; body: Record<string, any> }>).find(
|
||||
(item) => item.type === type && (name === undefined || item.body.name === name),
|
||||
);
|
||||
expect(event).toBeTruthy();
|
||||
return event!.body;
|
||||
}
|
||||
|
||||
describe('readLangfuseConfig', () => {
|
||||
it('returns null when keys are missing', () => {
|
||||
expect(readLangfuseConfig({})).toBeNull();
|
||||
expect(readLangfuseConfig({ LANGFUSE_PUBLIC_KEY: 'pk' })).toBeNull();
|
||||
expect(readLangfuseConfig({ LANGFUSE_SECRET_KEY: 'sk' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when keys are whitespace-only', () => {
|
||||
expect(
|
||||
readLangfuseConfig({
|
||||
LANGFUSE_PUBLIC_KEY: ' ',
|
||||
LANGFUSE_SECRET_KEY: 'sk',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('builds Basic auth header from public:secret', () => {
|
||||
const cfg = readLangfuseConfig({
|
||||
LANGFUSE_PUBLIC_KEY: 'pk-lf-abc',
|
||||
LANGFUSE_SECRET_KEY: 'sk-lf-xyz',
|
||||
});
|
||||
expect(cfg).not.toBeNull();
|
||||
const expected =
|
||||
'Basic ' + Buffer.from('pk-lf-abc:sk-lf-xyz').toString('base64');
|
||||
expect(cfg!.authHeader).toBe(expected);
|
||||
});
|
||||
|
||||
it('uses default US base URL when LANGFUSE_BASE_URL is absent', () => {
|
||||
const cfg = readLangfuseConfig({
|
||||
LANGFUSE_PUBLIC_KEY: 'pk',
|
||||
LANGFUSE_SECRET_KEY: 'sk',
|
||||
});
|
||||
expect(cfg!.baseUrl).toBe('https://us.cloud.langfuse.com');
|
||||
});
|
||||
|
||||
it('honours LANGFUSE_BASE_URL and strips trailing slashes', () => {
|
||||
const cfg = readLangfuseConfig({
|
||||
LANGFUSE_PUBLIC_KEY: 'pk',
|
||||
LANGFUSE_SECRET_KEY: 'sk',
|
||||
LANGFUSE_BASE_URL: 'https://cloud.langfuse.com//',
|
||||
});
|
||||
expect(cfg!.baseUrl).toBe('https://cloud.langfuse.com');
|
||||
});
|
||||
|
||||
it('reads optional timeout and retry tuning from env', () => {
|
||||
const cfg = readLangfuseConfig({
|
||||
LANGFUSE_PUBLIC_KEY: 'pk',
|
||||
LANGFUSE_SECRET_KEY: 'sk',
|
||||
LANGFUSE_TIMEOUT_MS: '45000',
|
||||
LANGFUSE_RETRIES: '2',
|
||||
});
|
||||
expect(cfg!.timeoutMs).toBe(45_000);
|
||||
expect(cfg!.retries).toBe(2);
|
||||
});
|
||||
|
||||
it('falls back when timeout and retry env values are invalid', () => {
|
||||
const cfg = readLangfuseConfig({
|
||||
LANGFUSE_PUBLIC_KEY: 'pk',
|
||||
LANGFUSE_SECRET_KEY: 'sk',
|
||||
LANGFUSE_TIMEOUT_MS: '-1',
|
||||
LANGFUSE_RETRIES: '-2',
|
||||
});
|
||||
expect(cfg!.timeoutMs).toBe(20_000);
|
||||
expect(cfg!.retries).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTracePayload', () => {
|
||||
it('emits a trace with nested agent + generation observations', () => {
|
||||
const batch = buildTracePayload(makeCtx());
|
||||
const types = (batch as Array<{ type: string }>).map((e) => e.type);
|
||||
expect(types).toEqual([
|
||||
'trace-create',
|
||||
'span-create',
|
||||
'generation-create',
|
||||
'span-create',
|
||||
'span-create',
|
||||
]);
|
||||
const span = bodyOf(batch, 'span-create', 'agent-run');
|
||||
const gen = bodyOf(batch, 'generation-create', 'llm');
|
||||
const bash = bodyOf(batch, 'span-create', 'tool:Bash');
|
||||
const write = bodyOf(batch, 'span-create', 'tool:Write');
|
||||
expect(span.id).toBe('run-1-agent');
|
||||
expect(span.traceId).toBe('run-1');
|
||||
expect(gen.traceId).toBe('run-1');
|
||||
expect(gen.parentObservationId).toBe('run-1-agent');
|
||||
expect(bash.parentObservationId).toBe('run-1-agent');
|
||||
expect(bash.input).toBeUndefined();
|
||||
expect(bash.output).toBeUndefined();
|
||||
expect(bash.metadata.toolName).toBe('Bash');
|
||||
expect(write.parentObservationId).toBe('run-1-agent');
|
||||
});
|
||||
|
||||
it('omits prompt + output when content gate is off', () => {
|
||||
const batch = buildTracePayload(makeCtx());
|
||||
const trace = (batch[0] as any).body;
|
||||
const span = bodyOf(batch, 'span-create', 'agent-run');
|
||||
const gen = bodyOf(batch, 'generation-create', 'llm');
|
||||
const tool = bodyOf(batch, 'span-create', 'tool:Bash');
|
||||
expect(trace.input).toBeUndefined();
|
||||
expect(trace.output).toBeUndefined();
|
||||
expect(span.input).toBeUndefined();
|
||||
expect(span.output).toBeUndefined();
|
||||
expect(gen.input).toBeUndefined();
|
||||
expect(gen.output).toBeUndefined();
|
||||
expect(tool.input).toBeUndefined();
|
||||
expect(tool.output).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes prompt + output when content gate is on', () => {
|
||||
const batch = buildTracePayload(
|
||||
makeCtx({
|
||||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||||
}),
|
||||
);
|
||||
const trace = (batch[0] as any).body;
|
||||
const tool = bodyOf(batch, 'span-create', 'tool:Bash');
|
||||
expect(trace.input).toMatch(/coffee shop/);
|
||||
expect(trace.output).toMatch(/landing page draft/);
|
||||
expect(tool.input).toMatch(/ls -la/);
|
||||
expect(tool.output).toBe('total 0');
|
||||
});
|
||||
|
||||
it('truncates ASCII prompt at 8 KB and output at 16 KB (bytes == chars)', () => {
|
||||
const longPrompt = 'a'.repeat(20_000);
|
||||
const longOutput = 'b'.repeat(40_000);
|
||||
const batch = buildTracePayload(
|
||||
makeCtx({
|
||||
message: {
|
||||
messageId: 'msg-1',
|
||||
prompt: longPrompt,
|
||||
output: longOutput,
|
||||
},
|
||||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||||
}),
|
||||
);
|
||||
const trace = (batch[0] as any).body;
|
||||
expect(Buffer.byteLength(trace.input, 'utf8')).toBe(8 * 1024);
|
||||
expect(Buffer.byteLength(trace.output, 'utf8')).toBe(16 * 1024);
|
||||
});
|
||||
|
||||
it('truncates by UTF-8 bytes, not by JS string length, for multi-byte text', () => {
|
||||
// Each CJK character is 3 bytes in UTF-8 but 1 unit in String.length.
|
||||
// 4096 chars × 3 bytes = 12_288 bytes, well over the 8 KB input cap.
|
||||
const longCJK = '设'.repeat(4096);
|
||||
expect(longCJK.length).toBe(4096);
|
||||
expect(Buffer.byteLength(longCJK, 'utf8')).toBe(12_288);
|
||||
const batch = buildTracePayload(
|
||||
makeCtx({
|
||||
message: { messageId: 'msg-1', prompt: longCJK, output: '' },
|
||||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||||
}),
|
||||
);
|
||||
const trace = (batch[0] as any).body;
|
||||
expect(Buffer.byteLength(trace.input, 'utf8')).toBeLessThanOrEqual(8 * 1024);
|
||||
// Boundary safety: the trimmed result must still be valid UTF-8 (no
|
||||
// half-encoded characters). Round-tripping through Buffer should be
|
||||
// lossless if the cut landed correctly.
|
||||
expect(Buffer.from(trace.input as string, 'utf8').toString('utf8')).toBe(
|
||||
trace.input,
|
||||
);
|
||||
// And every character is still '设', i.e. we didn't mangle the encoding.
|
||||
expect(/^设+$/.test(trace.input as string)).toBe(true);
|
||||
});
|
||||
|
||||
it('omits artifacts when manifest gate is off', () => {
|
||||
const batch = buildTracePayload(
|
||||
makeCtx({
|
||||
artifacts: [
|
||||
{ slug: 'a', type: 'html', sizeBytes: 100 },
|
||||
{ slug: 'b', type: 'jsx', sizeBytes: 200 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const trace = (batch[0] as any).body;
|
||||
expect(trace.metadata.artifacts).toBeUndefined();
|
||||
expect(trace.metadata.artifactsTruncated).toBeUndefined();
|
||||
});
|
||||
|
||||
it('caps artifacts at 50 entries with a truncation flag', () => {
|
||||
const many = Array.from({ length: 75 }, (_, i) => ({
|
||||
slug: `art-${i}`,
|
||||
type: 'html',
|
||||
sizeBytes: 1,
|
||||
}));
|
||||
const batch = buildTracePayload(
|
||||
makeCtx({
|
||||
artifacts: many,
|
||||
prefs: { metrics: true, content: false, artifactManifest: true },
|
||||
}),
|
||||
);
|
||||
const trace = (batch[0] as any).body;
|
||||
expect(trace.metadata.artifacts).toHaveLength(50);
|
||||
expect(trace.metadata.artifactsTruncated).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps eventsSummary metadata regardless of content / artifact gates', () => {
|
||||
const batch = buildTracePayload(makeCtx());
|
||||
const trace = (batch[0] as any).body;
|
||||
expect(trace.metadata.eventsSummary).toEqual({
|
||||
toolCalls: 2,
|
||||
errors: 0,
|
||||
durationMs: 4500,
|
||||
});
|
||||
});
|
||||
|
||||
it('records token counts in metadata.tokens and generation.usage', () => {
|
||||
const batch = buildTracePayload(makeCtx());
|
||||
const trace = (batch[0] as any).body;
|
||||
const gen = bodyOf(batch, 'generation-create', 'llm');
|
||||
expect(trace.metadata.tokens).toEqual({
|
||||
input: 1234,
|
||||
output: 567,
|
||||
total: 1801,
|
||||
});
|
||||
expect(gen.usage).toEqual({
|
||||
input: 1234,
|
||||
output: 567,
|
||||
total: 1801,
|
||||
unit: 'TOKENS',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses conversationId as sessionId when within length limit', () => {
|
||||
const batch = buildTracePayload(makeCtx());
|
||||
expect((batch[0] as any).body.sessionId).toBe(
|
||||
'conv-uuid-aaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops sessionId when conversationId exceeds 200 chars', () => {
|
||||
const batch = buildTracePayload(
|
||||
makeCtx({ conversationId: 'x'.repeat(201) }),
|
||||
);
|
||||
expect((batch[0] as any).body.sessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('builds tag list with project + agent + extras', () => {
|
||||
const batch = buildTracePayload(
|
||||
makeCtx({ extraTags: ['legacy:tag'] }),
|
||||
);
|
||||
expect((batch[0] as any).body.tags).toEqual([
|
||||
'open-design',
|
||||
'project:proj-1',
|
||||
'agent:claude',
|
||||
'legacy:tag',
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds turn-level tags (model / skill / DS) and runtime tags (os / client)', () => {
|
||||
const batch = buildTracePayload(
|
||||
makeCtx({
|
||||
turn: {
|
||||
model: 'gpt-4o',
|
||||
reasoning: 'high',
|
||||
skillId: 'landing-page',
|
||||
designSystemId: 'mission-control',
|
||||
},
|
||||
runtime: {
|
||||
os: 'darwin',
|
||||
arch: 'arm64',
|
||||
nodeVersion: 'v22.22.0',
|
||||
appVersion: '0.5.0',
|
||||
clientType: 'desktop',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect((batch[0] as any).body.tags).toEqual([
|
||||
'open-design',
|
||||
'project:proj-1',
|
||||
'agent:claude',
|
||||
'model:gpt-4o',
|
||||
'skill:landing-page',
|
||||
'ds:mission-control',
|
||||
'os:darwin',
|
||||
'client:desktop',
|
||||
]);
|
||||
});
|
||||
|
||||
it('promotes model + reasoning to first-class generation fields', () => {
|
||||
const batch = buildTracePayload(
|
||||
makeCtx({
|
||||
turn: { model: 'claude-sonnet-4-5', reasoning: 'high' },
|
||||
}),
|
||||
);
|
||||
const gen = bodyOf(batch, 'generation-create', 'llm');
|
||||
expect(gen.model).toBe('claude-sonnet-4-5');
|
||||
expect(gen.modelParameters).toEqual({ reasoning: 'high' });
|
||||
});
|
||||
|
||||
it('omits modelParameters entirely when reasoning is unset', () => {
|
||||
const batch = buildTracePayload(
|
||||
makeCtx({ turn: { model: 'gpt-4o' } }),
|
||||
);
|
||||
const gen = bodyOf(batch, 'generation-create', 'llm');
|
||||
expect(gen.model).toBe('gpt-4o');
|
||||
expect(gen.modelParameters).toBeUndefined();
|
||||
});
|
||||
|
||||
it('mirrors runtime + turn fields into trace metadata for query / export', () => {
|
||||
const batch = buildTracePayload(
|
||||
makeCtx({
|
||||
turn: { model: 'claude-sonnet-4-5', skillId: 'landing-page' },
|
||||
runtime: {
|
||||
os: 'linux',
|
||||
arch: 'x64',
|
||||
nodeVersion: 'v22.22.0',
|
||||
appVersion: '0.5.0',
|
||||
appChannel: 'beta',
|
||||
packaged: true,
|
||||
clientType: 'web',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const m = (batch[0] as any).body.metadata;
|
||||
expect(m.model).toBe('claude-sonnet-4-5');
|
||||
expect(m.skillId).toBe('landing-page');
|
||||
expect(m.os).toBe('linux');
|
||||
expect(m.arch).toBe('x64');
|
||||
expect(m.nodeVersion).toBe('v22.22.0');
|
||||
expect(m.appVersion).toBe('0.5.0');
|
||||
expect(m.appChannel).toBe('beta');
|
||||
expect(m.packaged).toBe(true);
|
||||
expect(m.clientType).toBe('web');
|
||||
expect(m.projectId).toBe('proj-1');
|
||||
expect(m.agent).toBe('claude');
|
||||
});
|
||||
|
||||
it('marks generation.level=ERROR when run failed', () => {
|
||||
const batch = buildTracePayload(
|
||||
makeCtx({
|
||||
run: {
|
||||
runId: 'run-1',
|
||||
status: 'failed',
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
error: 'boom',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const span = bodyOf(batch, 'span-create', 'agent-run');
|
||||
const gen = bodyOf(batch, 'generation-create', 'llm');
|
||||
expect(gen.level).toBe('ERROR');
|
||||
expect(gen.statusMessage).toBe('boom');
|
||||
expect(span.level).toBe('ERROR');
|
||||
expect(span.statusMessage).toBe('boom');
|
||||
expect(bodyOf(batch, 'event-create', 'run-error').statusMessage).toBe('boom');
|
||||
expect((batch[0] as any).body.metadata.error).toBe('boom');
|
||||
expect((batch[0] as any).body.metadata.success).toBe(false);
|
||||
});
|
||||
|
||||
it('passes through anonymous installationId as userId', () => {
|
||||
const batch = buildTracePayload(makeCtx({ installationId: null }));
|
||||
expect((batch[0] as any).body.userId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reportRunCompleted', () => {
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does nothing when metrics gate is off', async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
await reportRunCompleted(
|
||||
makeCtx({
|
||||
prefs: { metrics: false, content: true, artifactManifest: true },
|
||||
}),
|
||||
{ config: TEST_CONFIG, fetchImpl: fetchSpy as any },
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when no Langfuse config is available', async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
await reportRunCompleted(makeCtx(), {
|
||||
config: null,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('POSTs to /api/public/ingestion with Basic auth and a JSON batch body', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response('{}', { status: 200 }),
|
||||
);
|
||||
await reportRunCompleted(makeCtx(), {
|
||||
config: TEST_CONFIG,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const call = fetchSpy.mock.calls[0]!;
|
||||
const url = call[0] as string;
|
||||
const init = call[1] as RequestInit & { headers: Record<string, string> };
|
||||
expect(url).toBe('https://us.cloud.langfuse.com/api/public/ingestion');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers.Authorization).toBe('Basic dGVzdA==');
|
||||
expect(init.headers['Content-Type']).toBe('application/json');
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(Array.isArray(body.batch)).toBe(true);
|
||||
expect(body.batch.map((item: any) => item.type)).toEqual([
|
||||
'trace-create',
|
||||
'span-create',
|
||||
'generation-create',
|
||||
'span-create',
|
||||
'span-create',
|
||||
]);
|
||||
});
|
||||
|
||||
it('warns and drops when serialized batch exceeds the hard cap', async () => {
|
||||
// Per-field truncation already caps prompt/output, so we overflow the
|
||||
// hard cap by stuffing 50 artifact entries with very long slugs while
|
||||
// artifactManifest is on (50 × 30 KB ≈ 1.5 MB > 1 MB cap).
|
||||
const fetchSpy = vi.fn();
|
||||
const fatArtifacts = Array.from({ length: 50 }, (_, i) => ({
|
||||
slug: 'a'.repeat(30_000) + i,
|
||||
type: 'html',
|
||||
sizeBytes: 1,
|
||||
}));
|
||||
await reportRunCompleted(
|
||||
makeCtx({
|
||||
artifacts: fatArtifacts,
|
||||
prefs: { metrics: true, content: false, artifactManifest: true },
|
||||
}),
|
||||
{ config: TEST_CONFIG, fetchImpl: fetchSpy as any },
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Batch too large'),
|
||||
);
|
||||
});
|
||||
|
||||
it('only warns (does not throw) when fetch rejects', async () => {
|
||||
const fetchSpy = vi.fn().mockRejectedValue(new Error('network down'));
|
||||
await expect(
|
||||
reportRunCompleted(makeCtx(), {
|
||||
config: TEST_CONFIG,
|
||||
fetchImpl: fetchSpy as any,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Fetch error'),
|
||||
);
|
||||
});
|
||||
|
||||
it('retries once when fetch rejects before warning', async () => {
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('timeout'))
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 207 }));
|
||||
await reportRunCompleted(makeCtx(), {
|
||||
config: { ...TEST_CONFIG, retries: 1 },
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('only warns (does not throw) when ingestion responds non-2xx', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response('rate limited', { status: 429 }),
|
||||
);
|
||||
await reportRunCompleted(makeCtx(), {
|
||||
config: TEST_CONFIG,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Ingestion failed 429'),
|
||||
);
|
||||
});
|
||||
|
||||
it('warns when 207 Multi-Status body lists per-event errors', async () => {
|
||||
// Langfuse legacy ingestion always responds with 207. response.ok is
|
||||
// true, but malformed events show up in body.errors instead of as a
|
||||
// top-level non-2xx. Without parsing them they'd be silently dropped.
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
successes: [{ id: 'a', status: 201 }],
|
||||
errors: [
|
||||
{
|
||||
id: 'b',
|
||||
status: 400,
|
||||
message: 'invalid generation usage shape',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 207 },
|
||||
),
|
||||
);
|
||||
await reportRunCompleted(makeCtx(), {
|
||||
config: TEST_CONFIG,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Per-event errors (1)'),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not warn when 207 body has empty errors array', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
successes: [
|
||||
{ id: 'a', status: 201 },
|
||||
{ id: 'b', status: 201 },
|
||||
],
|
||||
errors: [],
|
||||
}),
|
||||
{ status: 207 },
|
||||
),
|
||||
);
|
||||
await reportRunCompleted(makeCtx(), {
|
||||
config: TEST_CONFIG,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
160
apps/daemon/tests/redact.test.ts
Normal file
160
apps/daemon/tests/redact.test.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { redactSecrets, redactSecretsWithCounts } from '../src/redact.js';
|
||||
|
||||
describe('redactSecrets', () => {
|
||||
it('returns empty / non-string input unchanged', () => {
|
||||
expect(redactSecrets('')).toBe('');
|
||||
expect(redactSecrets(undefined as unknown as string)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('redacts Anthropic / OpenAI sk-* keys', () => {
|
||||
expect(redactSecrets('use sk-ant-api03-AbCd1234efGh5678ijKl9012mnOp3456 today')).toBe(
|
||||
'use [REDACTED:sk_key] today',
|
||||
);
|
||||
expect(redactSecrets('paste sk-proj-abcdef1234567890ABCDEF here')).toBe(
|
||||
'paste [REDACTED:sk_key] here',
|
||||
);
|
||||
});
|
||||
|
||||
it('redacts Langfuse pk-lf- / sk-lf- keys', () => {
|
||||
expect(
|
||||
redactSecrets('export LANGFUSE_PUBLIC_KEY=pk-lf-12345678-aaaa-bbbb-cccc-dddddddddddd'),
|
||||
).toBe('export LANGFUSE_PUBLIC_KEY=[REDACTED:langfuse_key]');
|
||||
expect(
|
||||
redactSecrets('and LANGFUSE_SECRET_KEY=sk-lf-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'),
|
||||
).toBe('and LANGFUSE_SECRET_KEY=[REDACTED:langfuse_key]');
|
||||
});
|
||||
|
||||
it('redacts GitHub fine-grained / oauth tokens', () => {
|
||||
const ghp = 'ghp_' + 'a'.repeat(36);
|
||||
const gho = 'gho_' + 'b'.repeat(36);
|
||||
expect(redactSecrets(`token=${ghp}`)).toBe('token=[REDACTED:github_token]');
|
||||
expect(redactSecrets(`bearer ${gho}`)).toContain('[REDACTED:github_token]');
|
||||
});
|
||||
|
||||
it('redacts AWS access key id and Google API key', () => {
|
||||
expect(redactSecrets('AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE')).toBe(
|
||||
'AWS_ACCESS_KEY_ID=[REDACTED:aws_access_key]',
|
||||
);
|
||||
expect(redactSecrets('GMAPS=AIzaSyD-Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1')).toBe(
|
||||
'GMAPS=[REDACTED:google_api_key]',
|
||||
);
|
||||
});
|
||||
|
||||
it('redacts Slack and Stripe tokens', () => {
|
||||
expect(redactSecrets('SLACK=xoxb-12345-67890-abcdefghijKLMNOP')).toBe(
|
||||
'SLACK=[REDACTED:slack_token]',
|
||||
);
|
||||
// Build the Stripe-shaped test fixture at runtime so the literal
|
||||
// `sk_test_...` string never lands in source where GitHub's push
|
||||
// protection (and any other static secret scanner) would flag it.
|
||||
// The regex sees the full token at test time and matches.
|
||||
const stripeFixture = ['sk', 'test', 'X'.repeat(24)].join('_');
|
||||
expect(redactSecrets(`STRIPE=${stripeFixture}`)).toBe(
|
||||
'STRIPE=[REDACTED:stripe_key]',
|
||||
);
|
||||
});
|
||||
|
||||
it('redacts JWT triple', () => {
|
||||
const jwt =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJBYmMifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
|
||||
expect(redactSecrets(`Authorization: ${jwt}`)).toBe(
|
||||
'Authorization: [REDACTED:jwt]',
|
||||
);
|
||||
});
|
||||
|
||||
it('redacts Bearer-token values but keeps the scheme word readable', () => {
|
||||
expect(
|
||||
redactSecrets('Authorization: Bearer abcdef0123456789ABCDEF=='),
|
||||
).toBe('Authorization: Bearer [REDACTED:bearer_token]');
|
||||
});
|
||||
|
||||
it('redacts email addresses', () => {
|
||||
expect(redactSecrets('contact me at jane.doe+stuff@example.co.uk!')).toBe(
|
||||
'contact me at [REDACTED:email]!',
|
||||
);
|
||||
});
|
||||
|
||||
it('redacts IPv4 but not version-string look-alikes', () => {
|
||||
expect(redactSecrets('host 192.168.1.1 listens')).toBe(
|
||||
'host [REDACTED:ipv4] listens',
|
||||
);
|
||||
// 1.2.3.4 is technically a valid v4 — match.
|
||||
expect(redactSecrets('192.168.0.1, 10.0.0.1')).toBe(
|
||||
'[REDACTED:ipv4], [REDACTED:ipv4]',
|
||||
);
|
||||
// Out-of-range octets must not match.
|
||||
expect(redactSecrets('build 999.888.777.666 broken')).toBe(
|
||||
'build 999.888.777.666 broken',
|
||||
);
|
||||
});
|
||||
|
||||
it('redacts US-style phone numbers', () => {
|
||||
expect(redactSecrets('call (415) 555-1234 today')).toBe(
|
||||
'call [REDACTED:phone] today',
|
||||
);
|
||||
expect(redactSecrets('+1 415-555-1234 office')).toBe(
|
||||
'[REDACTED:phone] office',
|
||||
);
|
||||
});
|
||||
|
||||
it('redacts a Luhn-valid credit-card number', () => {
|
||||
// 4111-1111-1111-1111 is a canonical Visa test number that satisfies Luhn.
|
||||
expect(redactSecrets('paid with 4111 1111 1111 1111 thanks')).toBe(
|
||||
'paid with [REDACTED:credit_card] thanks',
|
||||
);
|
||||
expect(redactSecrets('card 5555-5555-5555-4444 charged')).toBe(
|
||||
'card [REDACTED:credit_card] charged',
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT redact 16-digit runs that fail Luhn (timestamps, IDs)', () => {
|
||||
// 1234567812345678 fails Luhn; should pass through.
|
||||
expect(redactSecrets('order id 1234567812345678 confirmed')).toBe(
|
||||
'order id 1234567812345678 confirmed',
|
||||
);
|
||||
// A 64-bit unix nanos timestamp also fails Luhn.
|
||||
expect(redactSecrets('ts=1700000000123456789')).toBe(
|
||||
'ts=1700000000123456789',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles multiple categories in one input', () => {
|
||||
const input =
|
||||
'API key sk-ant-test-AbCdEfGhIjKlMnOpQrStUvWxYz123456 from jane@example.com via 192.168.1.1';
|
||||
const out = redactSecrets(input);
|
||||
expect(out).toContain('[REDACTED:sk_key]');
|
||||
expect(out).toContain('[REDACTED:email]');
|
||||
expect(out).toContain('[REDACTED:ipv4]');
|
||||
});
|
||||
|
||||
it('is idempotent — redacting an already-redacted string is a no-op', () => {
|
||||
const once = redactSecrets('email is jane@example.com');
|
||||
expect(once).toBe('email is [REDACTED:email]');
|
||||
expect(redactSecrets(once)).toBe(once);
|
||||
});
|
||||
|
||||
it('leaves ordinary prose untouched', () => {
|
||||
const prose =
|
||||
'Make a landing page for a coffee shop. The hero needs three columns and a warm color palette.';
|
||||
expect(redactSecrets(prose)).toBe(prose);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactSecretsWithCounts', () => {
|
||||
it('returns per-category counts alongside redacted output', () => {
|
||||
const input =
|
||||
'keys: sk-ant-test-AbCdEfGhIjKlMnOpQrStUvWxYz123456 and sk-proj-AAAAAAAAAAAAAAAAAAAA, mail jane@x.com, ip 10.0.0.1';
|
||||
const { redacted, counts } = redactSecretsWithCounts(input);
|
||||
expect(redacted).toContain('[REDACTED:sk_key]');
|
||||
expect(counts.sk_key).toBe(2);
|
||||
expect(counts.email).toBe(1);
|
||||
expect(counts.ipv4).toBe(1);
|
||||
});
|
||||
|
||||
it('returns empty counts when nothing matched', () => {
|
||||
const { redacted, counts } = redactSecretsWithCounts('no secrets here');
|
||||
expect(redacted).toBe('no secrets here');
|
||||
expect(counts).toEqual({});
|
||||
});
|
||||
});
|
||||
58
apps/daemon/tests/telemetry-message-finalization.test.ts
Normal file
58
apps/daemon/tests/telemetry-message-finalization.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
shouldReportRunCompletedFromMessage,
|
||||
telemetryPromptFromRunRequest,
|
||||
} from '../src/server.js';
|
||||
|
||||
describe('Langfuse message finalization gate', () => {
|
||||
const terminalMessage = {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'final answer',
|
||||
runId: 'run-1',
|
||||
runStatus: 'succeeded',
|
||||
};
|
||||
|
||||
it('does not report when only terminal runStatus has been persisted', () => {
|
||||
expect(
|
||||
shouldReportRunCompletedFromMessage(terminalMessage, {
|
||||
...terminalMessage,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('reports only on the final telemetry-marked message write', () => {
|
||||
expect(
|
||||
shouldReportRunCompletedFromMessage(terminalMessage, {
|
||||
...terminalMessage,
|
||||
producedFiles: [],
|
||||
telemetryFinalized: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores non-terminal run statuses even if marked finalized', () => {
|
||||
expect(
|
||||
shouldReportRunCompletedFromMessage(
|
||||
{ ...terminalMessage, runStatus: 'running' },
|
||||
{ telemetryFinalized: true },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the explicit current prompt for telemetry instead of the full transcript', () => {
|
||||
expect(
|
||||
telemetryPromptFromRunRequest(
|
||||
'## user\npre-consent brief\n\n## assistant\ndraft\n\n## user\npost-consent revision',
|
||||
'post-consent revision',
|
||||
),
|
||||
).toBe('post-consent revision');
|
||||
});
|
||||
|
||||
it('falls back to the legacy message when currentPrompt is absent', () => {
|
||||
expect(telemetryPromptFromRunRequest('legacy prompt', undefined)).toBe(
|
||||
'legacy prompt',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
SettingsDialog,
|
||||
type SettingsSection,
|
||||
} from './components/SettingsDialog';
|
||||
import { PrivacyConsentModal } from './components/PrivacyConsentModal';
|
||||
import {
|
||||
daemonIsLive,
|
||||
fetchAppVersionInfo,
|
||||
|
|
@ -171,6 +172,8 @@ export function App() {
|
|||
// {active:false} if this hasn't run.
|
||||
const activeProjectId = route.kind === 'project' ? route.projectId : null;
|
||||
const activeFileName = route.kind === 'project' ? route.fileName : null;
|
||||
const showPrivacyConsent =
|
||||
daemonConfigLoaded && config.privacyDecisionAt == null && !settingsOpen;
|
||||
useEffect(() => {
|
||||
const body = activeProjectId
|
||||
? { projectId: activeProjectId, fileName: activeFileName }
|
||||
|
|
@ -279,8 +282,10 @@ export function App() {
|
|||
|
||||
// Pop the onboarding modal only on the first run. Once the user
|
||||
// has saved or skipped past it once, we trust their stored config
|
||||
// and let them re-open Settings explicitly via the env pill.
|
||||
if (!next.onboardingCompleted) {
|
||||
// and let them re-open Settings explicitly via the env pill. Hold
|
||||
// the welcome modal until the privacy decision is resolved; the
|
||||
// installation id can rotate later without re-opening the banner.
|
||||
if (!next.onboardingCompleted && next.privacyDecisionAt != null) {
|
||||
setSettingsWelcome(true);
|
||||
setSettingsOpen(true);
|
||||
}
|
||||
|
|
@ -789,6 +794,49 @@ export function App() {
|
|||
onRefreshAgents={refreshAgents}
|
||||
/>
|
||||
) : null}
|
||||
{/* First-run privacy consent banner. It waits for daemon config
|
||||
hydration because privacyDecisionAt is daemon-owned and stripped
|
||||
from localStorage. It also yields while Settings is open so the
|
||||
floating banner never intercepts modal interactions. */}
|
||||
{showPrivacyConsent ? (
|
||||
<PrivacyConsentModal
|
||||
onShare={() => {
|
||||
const installationId = generateInstallationIdSafe();
|
||||
void handleConfigPersist({
|
||||
...latestPersistedConfigRef.current,
|
||||
installationId,
|
||||
privacyDecisionAt: Date.now(),
|
||||
telemetry: { metrics: true, content: true, artifactManifest: false },
|
||||
});
|
||||
// Hand the foreground over to the welcome modal now that the
|
||||
// privacy decision is recorded — bootstrap deferred opening
|
||||
// it while consent was pending.
|
||||
if (!latestPersistedConfigRef.current.onboardingCompleted) {
|
||||
setSettingsWelcome(true);
|
||||
setSettingsOpen(true);
|
||||
}
|
||||
}}
|
||||
onDecline={() => {
|
||||
void handleConfigPersist({
|
||||
...latestPersistedConfigRef.current,
|
||||
installationId: null,
|
||||
privacyDecisionAt: Date.now(),
|
||||
telemetry: { metrics: false, content: false, artifactManifest: false },
|
||||
});
|
||||
if (!latestPersistedConfigRef.current.onboardingCompleted) {
|
||||
setSettingsWelcome(true);
|
||||
setSettingsOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function generateInstallationIdSafe(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `inst-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
|
|
|||
65
apps/web/src/components/PrivacyConsentModal.tsx
Normal file
65
apps/web/src/components/PrivacyConsentModal.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type { CSSProperties } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
|
||||
interface Props {
|
||||
/** Affirmative consent (Share usage data). */
|
||||
onShare: () => void;
|
||||
/** Decline (Don't share). */
|
||||
onDecline: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* First-run privacy consent banner.
|
||||
*
|
||||
* Anchored to the bottom-right of the viewport (cookie-consent style)
|
||||
* so it's prominently visible without blocking the underlying app —
|
||||
* the user can move around and read while deciding. The two action
|
||||
* buttons share an equal-prominence seg-control so the reject path
|
||||
* is not visually de-emphasised, the EDPB equal-prominence requirement
|
||||
* under GDPR.
|
||||
*
|
||||
* Stays mounted until the user picks Share or Don't share — there is
|
||||
* no dismiss-without-choice button on purpose. Telemetry decisions
|
||||
* downstream key off whether `installationId` is set, so an "ambiguous
|
||||
* not-yet-decided" state would be hard to interpret.
|
||||
*/
|
||||
export function PrivacyConsentModal({ onShare, onDecline }: Props): JSX.Element {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="privacy-consent-banner" role="region" aria-labelledby="privacy-consent-title">
|
||||
<div className="privacy-consent-banner-head">
|
||||
<span className="kicker">{t('settings.privacy')}</span>
|
||||
<h3 id="privacy-consent-title">{t('settings.privacyConsentKicker')}</h3>
|
||||
</div>
|
||||
|
||||
<p className="privacy-consent-banner-lead">{t('settings.privacyConsentLead')}</p>
|
||||
|
||||
<dl className="settings-privacy-disclosure">
|
||||
<div>
|
||||
<dt>{t('settings.privacyMetrics')}</dt>
|
||||
<dd>{t('settings.privacyMetricsHint')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('settings.privacyContent')}</dt>
|
||||
<dd>{t('settings.privacyContentHint')}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p className="hint privacy-consent-banner-footer">{t('settings.privacyConsentFooter')}</p>
|
||||
|
||||
<div
|
||||
className="seg-control"
|
||||
role="group"
|
||||
aria-label={t('settings.privacyConsentKicker')}
|
||||
style={{ ['--seg-cols' as string]: 2 } as CSSProperties}
|
||||
>
|
||||
<button type="button" className="seg-btn active" onClick={onShare}>
|
||||
<span className="seg-title">{t('settings.privacyConsentShare')}</span>
|
||||
</button>
|
||||
<button type="button" className="seg-btn" onClick={onDecline}>
|
||||
<span className="seg-title">{t('settings.privacyConsentDecline')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
212
apps/web/src/components/PrivacySection.tsx
Normal file
212
apps/web/src/components/PrivacySection.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import type { CSSProperties, Dispatch, SetStateAction } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import { Icon } from './Icon';
|
||||
import type { AppConfig, TelemetryConfig } from '../types';
|
||||
|
||||
interface Props {
|
||||
cfg: AppConfig;
|
||||
setCfg: Dispatch<SetStateAction<AppConfig>>;
|
||||
}
|
||||
|
||||
function generateInstallationId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Older webviews / test runners that lack crypto.randomUUID. The output
|
||||
// is opaque and non-PII; we only need uniqueness across installs.
|
||||
return `inst-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function PrivacySection({ cfg, setCfg }: Props): JSX.Element {
|
||||
const t = useT();
|
||||
const telemetry: TelemetryConfig = cfg.telemetry ?? {};
|
||||
// `privacyDecisionAt` gates the consent surface. installationId is only
|
||||
// the anonymous reporting id and can be rotated by Delete my data without
|
||||
// making the first-run banner appear again.
|
||||
const hasMadeConsentDecision = cfg.privacyDecisionAt != null;
|
||||
|
||||
function patchTelemetry(patch: Partial<TelemetryConfig>): void {
|
||||
setCfg((c) => {
|
||||
const nextTelemetry = { ...(c.telemetry ?? {}), ...patch };
|
||||
const shouldHaveId = Object.values(nextTelemetry).some((v) => v === true);
|
||||
return {
|
||||
...c,
|
||||
installationId:
|
||||
shouldHaveId && !c.installationId
|
||||
? generateInstallationId()
|
||||
: c.installationId,
|
||||
privacyDecisionAt: Date.now(),
|
||||
telemetry: nextTelemetry,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function shareUsage(): void {
|
||||
setCfg((c) => ({
|
||||
...c,
|
||||
installationId: generateInstallationId(),
|
||||
privacyDecisionAt: Date.now(),
|
||||
telemetry: { metrics: true, content: true, artifactManifest: false },
|
||||
}));
|
||||
}
|
||||
|
||||
function declineUsage(): void {
|
||||
setCfg((c) => ({
|
||||
...c,
|
||||
installationId: null,
|
||||
privacyDecisionAt: Date.now(),
|
||||
telemetry: { metrics: false, content: false, artifactManifest: false },
|
||||
}));
|
||||
}
|
||||
|
||||
function deleteMyData(): void {
|
||||
setCfg((c) => ({
|
||||
...c,
|
||||
installationId: generateInstallationId(),
|
||||
privacyDecisionAt: c.privacyDecisionAt ?? Date.now(),
|
||||
telemetry: { metrics: false, content: false, artifactManifest: false },
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h3>{t('settings.privacy')}</h3>
|
||||
<p className="hint">{t('settings.privacyHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasMadeConsentDecision ? (
|
||||
<ConsentCard onShare={shareUsage} onDecline={declineUsage} />
|
||||
) : (
|
||||
<>
|
||||
<div className="settings-privacy-toggles">
|
||||
<ToggleRow
|
||||
label={t('settings.privacyMetrics')}
|
||||
hint={t('settings.privacyMetricsHint')}
|
||||
checked={telemetry.metrics === true}
|
||||
onChange={(v) => patchTelemetry({ metrics: v })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t('settings.privacyContent')}
|
||||
hint={t('settings.privacyContentHint')}
|
||||
checked={telemetry.content === true}
|
||||
onChange={(v) => patchTelemetry({ content: v })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t('settings.privacyArtifacts')}
|
||||
hint={t('settings.privacyArtifactsHint')}
|
||||
checked={telemetry.artifactManifest === true}
|
||||
onChange={(v) => patchTelemetry({ artifactManifest: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-subsection">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h4>{t('settings.privacyInstallationId')}</h4>
|
||||
<p className="hint">{t('settings.privacyDataDeletionHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={cfg.installationId ?? t('settings.privacyOptedOut')}
|
||||
aria-label={t('settings.privacyInstallationId')}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={deleteMyData}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
<Icon name="refresh" size={13} />
|
||||
<span style={{ marginLeft: 6 }}>{t('settings.privacyDataDeletion')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToggleRowProps {
|
||||
label: string;
|
||||
hint: string;
|
||||
checked: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
}
|
||||
|
||||
// Reuses .toggle-row (label + hint + iOS-style switch) — same control
|
||||
// NewProjectPanel uses for "speaker notes" / "animations" toggles, so the
|
||||
// Privacy panel reads as native to the rest of the app.
|
||||
function ToggleRow({ label, hint, checked, onChange }: ToggleRowProps): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`toggle-row${checked ? ' on' : ''}`}
|
||||
onClick={() => onChange(!checked)}
|
||||
aria-pressed={checked}
|
||||
>
|
||||
<div className="toggle-row-text">
|
||||
<span className="toggle-row-label">{label}</span>
|
||||
<span className="toggle-row-hint">{hint}</span>
|
||||
</div>
|
||||
<span className="toggle-row-switch" aria-hidden />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConsentProps {
|
||||
onShare: () => void;
|
||||
onDecline: () => void;
|
||||
}
|
||||
|
||||
function ConsentCard({ onShare, onDecline }: ConsentProps): JSX.Element {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="settings-subsection">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h4>{t('settings.privacyConsentKicker')}</h4>
|
||||
<p className="hint">{t('settings.privacyConsentLead')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="settings-privacy-disclosure">
|
||||
<div>
|
||||
<dt>{t('settings.privacyMetrics')}</dt>
|
||||
<dd>{t('settings.privacyMetricsHint')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('settings.privacyContent')}</dt>
|
||||
<dd>{t('settings.privacyContentHint')}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p className="hint">{t('settings.privacyConsentFooter')}</p>
|
||||
|
||||
{/* Two-column seg-control gives both buttons identical visual weight,
|
||||
which is what GDPR/EDPB asks for ("equal prominence" between
|
||||
accept and reject). The accept side carries the active highlight
|
||||
to mark it as the affirmative action without making the reject
|
||||
side smaller or dimmer. */}
|
||||
<div
|
||||
className="seg-control"
|
||||
role="group"
|
||||
aria-label={t('settings.privacyConsentKicker')}
|
||||
style={{ ['--seg-cols' as string]: 2 } as CSSProperties}
|
||||
>
|
||||
<button type="button" className="seg-btn active" onClick={onShare}>
|
||||
<span className="seg-title">{t('settings.privacyConsentShare')}</span>
|
||||
</button>
|
||||
<button type="button" className="seg-btn" onClick={onDecline}>
|
||||
<span className="seg-title">{t('settings.privacyConsentDecline')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -54,6 +54,7 @@ import {
|
|||
patchProject,
|
||||
saveMessage,
|
||||
saveTabs,
|
||||
type SaveMessageOptions,
|
||||
} from '../state/projects';
|
||||
import type {
|
||||
AgentEvent,
|
||||
|
|
@ -651,19 +652,19 @@ export function ProjectView({
|
|||
]);
|
||||
|
||||
const persistMessage = useCallback(
|
||||
(m: ChatMessage) => {
|
||||
(m: ChatMessage, options?: SaveMessageOptions) => {
|
||||
if (!activeConversationId) return;
|
||||
void saveMessage(project.id, activeConversationId, m);
|
||||
void saveMessage(project.id, activeConversationId, m, options);
|
||||
},
|
||||
[project.id, activeConversationId],
|
||||
);
|
||||
|
||||
const persistMessageById = useCallback(
|
||||
(messageId: string) => {
|
||||
(messageId: string, options?: SaveMessageOptions) => {
|
||||
if (!activeConversationId) return;
|
||||
setMessages((curr) => {
|
||||
const found = curr.find((m) => m.id === messageId);
|
||||
if (found) void saveMessage(project.id, activeConversationId, found);
|
||||
if (found) void saveMessage(project.id, activeConversationId, found, options);
|
||||
return curr;
|
||||
});
|
||||
},
|
||||
|
|
@ -671,7 +672,12 @@ export function ProjectView({
|
|||
);
|
||||
|
||||
const updateMessageById = useCallback(
|
||||
(messageId: string, updater: (message: ChatMessage) => ChatMessage, persist = false) => {
|
||||
(
|
||||
messageId: string,
|
||||
updater: (message: ChatMessage) => ChatMessage,
|
||||
persist = false,
|
||||
persistOptions?: SaveMessageOptions,
|
||||
) => {
|
||||
setMessages((curr) => {
|
||||
let saved: ChatMessage | null = null;
|
||||
const next = curr.map((m) => {
|
||||
|
|
@ -681,7 +687,7 @@ export function ProjectView({
|
|||
return updated;
|
||||
});
|
||||
if (persist && saved && activeConversationId) {
|
||||
void saveMessage(project.id, activeConversationId, saved);
|
||||
void saveMessage(project.id, activeConversationId, saved, persistOptions);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
|
@ -842,13 +848,13 @@ export function ProjectView({
|
|||
persistMessageById(message.id);
|
||||
}, 500);
|
||||
};
|
||||
const persistNow = () => {
|
||||
const persistNow = (options?: SaveMessageOptions) => {
|
||||
if (persistTimer) {
|
||||
clearTimeout(persistTimer);
|
||||
persistTimer = null;
|
||||
}
|
||||
textBuffer.flush();
|
||||
persistMessageById(message.id);
|
||||
persistMessageById(message.id, options);
|
||||
};
|
||||
const textBuffer = createBufferedTextUpdates({
|
||||
updateMessage: (updater) => updateMessageById(message.id, updater),
|
||||
|
|
@ -886,7 +892,7 @@ export function ProjectView({
|
|||
if (abortRef.current === controller) abortRef.current = null;
|
||||
if (cancelRef.current === cancelController) cancelRef.current = null;
|
||||
setStreaming(false);
|
||||
persistNow();
|
||||
persistNow({ telemetryFinalized: true });
|
||||
void refreshProjectFiles();
|
||||
onProjectsRefresh();
|
||||
},
|
||||
|
|
@ -907,7 +913,7 @@ export function ProjectView({
|
|||
if (abortRef.current === controller) abortRef.current = null;
|
||||
if (cancelRef.current === cancelController) cancelRef.current = null;
|
||||
setStreaming(false);
|
||||
persistNow();
|
||||
persistNow({ telemetryFinalized: true });
|
||||
},
|
||||
},
|
||||
onRunStatus: (runStatus) => {
|
||||
|
|
@ -930,7 +936,7 @@ export function ProjectView({
|
|||
if (abortRef.current === controller) abortRef.current = null;
|
||||
if (cancelRef.current === cancelController) cancelRef.current = null;
|
||||
setStreaming(false);
|
||||
persistNow();
|
||||
persistNow({ telemetryFinalized: true });
|
||||
}
|
||||
},
|
||||
onRunEventId: (lastRunEventId) => {
|
||||
|
|
@ -948,6 +954,7 @@ export function ProjectView({
|
|||
message.id,
|
||||
(prev) => ({ ...prev, runStatus: 'failed', endedAt: prev.endedAt ?? Date.now() }),
|
||||
true,
|
||||
{ telemetryFinalized: true },
|
||||
);
|
||||
}
|
||||
})
|
||||
|
|
@ -1215,13 +1222,11 @@ export function ProjectView({
|
|||
setMessages((curr) => {
|
||||
const updated = curr.map((m) =>
|
||||
m.id === assistantId
|
||||
? produced.length > 0
|
||||
? { ...m, producedFiles: produced }
|
||||
: m
|
||||
? { ...m, producedFiles: produced }
|
||||
: m,
|
||||
);
|
||||
const finalized = updated.find((m) => m.id === assistantId);
|
||||
if (finalized) persistMessage(finalized);
|
||||
if (finalized) persistMessage(finalized, { telemetryFinalized: true });
|
||||
return updated;
|
||||
});
|
||||
});
|
||||
|
|
@ -1248,7 +1253,7 @@ export function ProjectView({
|
|||
cancelRef.current = null;
|
||||
setMessages((curr) => {
|
||||
const finalized = curr.find((m) => m.id === assistantId);
|
||||
if (finalized) persistMessage(finalized);
|
||||
if (finalized) persistMessage(finalized, { telemetryFinalized: true });
|
||||
return curr;
|
||||
});
|
||||
void refreshProjectFiles();
|
||||
|
|
@ -1290,6 +1295,7 @@ export function ProjectView({
|
|||
endedAt: isTerminalRunStatus(runStatus) ? prev.endedAt ?? Date.now() : prev.endedAt,
|
||||
}),
|
||||
true,
|
||||
runStatus === 'canceled' ? { telemetryFinalized: true } : undefined,
|
||||
);
|
||||
},
|
||||
onRunEventId: (lastRunEventId) => {
|
||||
|
|
@ -1496,7 +1502,7 @@ export function ProjectView({
|
|||
}
|
||||
return m;
|
||||
});
|
||||
for (const message of finalized) persistMessage(message);
|
||||
for (const message of finalized) persistMessage(message, { telemetryFinalized: true });
|
||||
return next;
|
||||
});
|
||||
}, [cancelSendTextBuffer, cancelReattachTextBuffers, persistMessage]);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import type { MediaProvider } from '../media/models';
|
|||
import { PetSettings } from './pet/PetSettings';
|
||||
import { McpClientSection } from './McpClientSection';
|
||||
import { LibrarySection } from './LibrarySection';
|
||||
import { PrivacySection } from './PrivacySection';
|
||||
import { ConnectorsBrowser } from './ConnectorsBrowser';
|
||||
import {
|
||||
applyAppearanceToDocument,
|
||||
|
|
@ -70,6 +71,7 @@ export type SettingsSection =
|
|||
| 'notifications'
|
||||
| 'pet'
|
||||
| 'library'
|
||||
| 'privacy'
|
||||
| 'about';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -1007,6 +1009,7 @@ export function SettingsDialog({
|
|||
language: { title: t('settings.language'), subtitle: t('settings.languageHint') },
|
||||
appearance: { title: t('settings.appearance'), subtitle: t('settings.appearanceHint') },
|
||||
notifications: { title: t('settings.notifications'), subtitle: t('settings.notificationsHint') },
|
||||
privacy: { title: t('settings.privacy'), subtitle: t('settings.privacyHint') },
|
||||
pet: { title: t('pet.title'), subtitle: t('pet.subtitle') },
|
||||
library: { title: t('settings.library'), subtitle: t('settings.libraryHint') },
|
||||
about: { title: t('settings.about'), subtitle: t('settings.aboutHint') },
|
||||
|
|
@ -1229,6 +1232,17 @@ export function SettingsDialog({
|
|||
<small>{t('settings.libraryHint')}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`settings-nav-item${activeSection === 'privacy' ? ' active' : ''}`}
|
||||
onClick={() => setActiveSection('privacy')}
|
||||
>
|
||||
<Icon name="eye" size={18} />
|
||||
<span>
|
||||
<strong>{t('settings.privacy')}</strong>
|
||||
<small>{t('settings.privacyHint')}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`settings-nav-item${activeSection === 'about' ? ' active' : ''}`}
|
||||
|
|
@ -1926,6 +1940,10 @@ export function SettingsDialog({
|
|||
<LibrarySection cfg={cfg} setCfg={setCfg} />
|
||||
) : null}
|
||||
|
||||
{activeSection === 'privacy' ? (
|
||||
<PrivacySection cfg={cfg} setCfg={setCfg} />
|
||||
) : null}
|
||||
|
||||
{activeSection === 'about' ? (
|
||||
<section className="settings-section">
|
||||
<div className="section-head">
|
||||
|
|
|
|||
|
|
@ -149,6 +149,23 @@ export const ar: Dict = {
|
|||
'settings.mediaProviderClearConfirm': 'مسح إعدادات {name} المحفوظة؟ ستحتاج إلى إدخالها مرة أخرى لاستخدام {name}.',
|
||||
'settings.mediaProviderPlaceholder': 'الصق مفتاح API',
|
||||
'settings.mediaProviderBaseUrlPlaceholder': 'تجاوز رابط القاعدة الافتراضي',
|
||||
'settings.privacy': 'Privacy',
|
||||
'settings.privacyHint': 'What data is shared with the Open Design team',
|
||||
'settings.privacyConsentKicker': 'Help us improve Open Design',
|
||||
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
|
||||
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
|
||||
'settings.privacyConsentShare': 'Help improve',
|
||||
'settings.privacyConsentDecline': 'Not now',
|
||||
'settings.privacyMetrics': 'Anonymous metrics',
|
||||
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
|
||||
'settings.privacyContent': 'Conversation content',
|
||||
'settings.privacyContentHint': "Your prompts and the assistant's responses (truncated 8 KB / 16 KB). API keys, tokens, JWTs, emails, IPs, and credit-card numbers are stripped automatically before send.",
|
||||
'settings.privacyArtifacts': 'Project artifacts manifest',
|
||||
'settings.privacyArtifactsHint': 'Filenames, types, sizes of generated files. File contents are never sent.',
|
||||
'settings.privacyInstallationId': 'Anonymous ID',
|
||||
'settings.privacyOptedOut': 'opted out',
|
||||
'settings.privacyDataDeletion': 'Delete my data',
|
||||
'settings.privacyDataDeletionHint': 'Rotates your anonymous ID and stops sending. Existing traces age out under our retention policy.',
|
||||
'settings.about': 'حول',
|
||||
'settings.aboutHint': 'تفاصيل النسخة ووقت التشغيل',
|
||||
'settings.appVersion': 'النسخة',
|
||||
|
|
|
|||
|
|
@ -149,6 +149,23 @@ export const de: Dict = {
|
|||
'settings.mediaProviderClearConfirm': 'Gespeicherte {name}-Einstellungen löschen? Du musst sie erneut eingeben, um {name} zu nutzen.',
|
||||
'settings.mediaProviderPlaceholder': 'API-Key einfügen',
|
||||
'settings.mediaProviderBaseUrlPlaceholder': 'Standard-Base-URL überschreiben',
|
||||
'settings.privacy': 'Privacy',
|
||||
'settings.privacyHint': 'What data is shared with the Open Design team',
|
||||
'settings.privacyConsentKicker': 'Help us improve Open Design',
|
||||
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
|
||||
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
|
||||
'settings.privacyConsentShare': 'Help improve',
|
||||
'settings.privacyConsentDecline': 'Not now',
|
||||
'settings.privacyMetrics': 'Anonymous metrics',
|
||||
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
|
||||
'settings.privacyContent': 'Conversation content',
|
||||
'settings.privacyContentHint': "Your prompts and the assistant's responses (truncated 8 KB / 16 KB). API keys, tokens, JWTs, emails, IPs, and credit-card numbers are stripped automatically before send.",
|
||||
'settings.privacyArtifacts': 'Project artifacts manifest',
|
||||
'settings.privacyArtifactsHint': 'Filenames, types, sizes of generated files. File contents are never sent.',
|
||||
'settings.privacyInstallationId': 'Anonymous ID',
|
||||
'settings.privacyOptedOut': 'opted out',
|
||||
'settings.privacyDataDeletion': 'Delete my data',
|
||||
'settings.privacyDataDeletionHint': 'Rotates your anonymous ID and stops sending. Existing traces age out under our retention policy.',
|
||||
'settings.about': 'Info',
|
||||
'settings.aboutHint': 'Version und Laufzeitdetails',
|
||||
'settings.appVersion': 'Version',
|
||||
|
|
|
|||
|
|
@ -147,6 +147,23 @@ export const en: Dict = {
|
|||
'settings.mediaProviderClearConfirm': 'Clear saved {name} settings? You\'ll need to enter them again to use {name}.',
|
||||
'settings.mediaProviderPlaceholder': 'Paste API key',
|
||||
'settings.mediaProviderBaseUrlPlaceholder': 'Override default base URL',
|
||||
'settings.privacy': 'Privacy',
|
||||
'settings.privacyHint': 'What data is shared with the Open Design team',
|
||||
'settings.privacyConsentKicker': 'Help us improve Open Design',
|
||||
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
|
||||
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
|
||||
'settings.privacyConsentShare': 'Help improve',
|
||||
'settings.privacyConsentDecline': 'Not now',
|
||||
'settings.privacyMetrics': 'Anonymous metrics',
|
||||
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
|
||||
'settings.privacyContent': 'Conversation content',
|
||||
'settings.privacyContentHint': "Your prompts and the assistant's responses (truncated 8 KB / 16 KB). API keys, tokens, JWTs, emails, IPs, and credit-card numbers are stripped automatically before send.",
|
||||
'settings.privacyArtifacts': 'Project artifacts manifest',
|
||||
'settings.privacyArtifactsHint': 'Filenames, types, sizes of generated files. File contents are never sent.',
|
||||
'settings.privacyInstallationId': 'Anonymous ID',
|
||||
'settings.privacyOptedOut': 'opted out',
|
||||
'settings.privacyDataDeletion': 'Delete my data',
|
||||
'settings.privacyDataDeletionHint': 'Rotates your anonymous ID and stops sending. Existing traces age out under our retention policy.',
|
||||
'settings.about': 'About',
|
||||
'settings.aboutHint': 'Version and runtime details',
|
||||
'settings.appVersion': 'Version',
|
||||
|
|
|
|||
|
|
@ -149,6 +149,23 @@ export const esES: Dict = {
|
|||
'settings.mediaProviderClearConfirm': '¿Eliminar la configuración guardada de {name}? Tendrás que introducirla de nuevo para usar {name}.',
|
||||
'settings.mediaProviderPlaceholder': 'Pega la clave de API',
|
||||
'settings.mediaProviderBaseUrlPlaceholder': 'Sobrescribir URL base por defecto',
|
||||
'settings.privacy': 'Privacy',
|
||||
'settings.privacyHint': 'What data is shared with the Open Design team',
|
||||
'settings.privacyConsentKicker': 'Help us improve Open Design',
|
||||
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
|
||||
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
|
||||
'settings.privacyConsentShare': 'Help improve',
|
||||
'settings.privacyConsentDecline': 'Not now',
|
||||
'settings.privacyMetrics': 'Anonymous metrics',
|
||||
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
|
||||
'settings.privacyContent': 'Conversation content',
|
||||
'settings.privacyContentHint': "Your prompts and the assistant's responses (truncated 8 KB / 16 KB). API keys, tokens, JWTs, emails, IPs, and credit-card numbers are stripped automatically before send.",
|
||||
'settings.privacyArtifacts': 'Project artifacts manifest',
|
||||
'settings.privacyArtifactsHint': 'Filenames, types, sizes of generated files. File contents are never sent.',
|
||||
'settings.privacyInstallationId': 'Anonymous ID',
|
||||
'settings.privacyOptedOut': 'opted out',
|
||||
'settings.privacyDataDeletion': 'Delete my data',
|
||||
'settings.privacyDataDeletionHint': 'Rotates your anonymous ID and stops sending. Existing traces age out under our retention policy.',
|
||||
'settings.about': 'Acerca de',
|
||||
'settings.aboutHint': 'Versión y detalles de ejecución',
|
||||
'settings.appVersion': 'Versión',
|
||||
|
|
|
|||
|
|
@ -147,6 +147,23 @@ export const fa: Dict = {
|
|||
'settings.mediaProviderClearConfirm': 'پاک کردن تنظیمات ذخیرهشدهی {name}؟ برای استفاده از {name} باید آنها را دوباره وارد کنید.',
|
||||
'settings.mediaProviderPlaceholder': 'کلید API را وارد کنید',
|
||||
'settings.mediaProviderBaseUrlPlaceholder': 'بازنویسی آدرس پایه پیشفرض',
|
||||
'settings.privacy': 'Privacy',
|
||||
'settings.privacyHint': 'What data is shared with the Open Design team',
|
||||
'settings.privacyConsentKicker': 'Help us improve Open Design',
|
||||
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
|
||||
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
|
||||
'settings.privacyConsentShare': 'Help improve',
|
||||
'settings.privacyConsentDecline': 'Not now',
|
||||
'settings.privacyMetrics': 'Anonymous metrics',
|
||||
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
|
||||
'settings.privacyContent': 'Conversation content',
|
||||
'settings.privacyContentHint': "Your prompts and the assistant's responses (truncated 8 KB / 16 KB). API keys, tokens, JWTs, emails, IPs, and credit-card numbers are stripped automatically before send.",
|
||||
'settings.privacyArtifacts': 'Project artifacts manifest',
|
||||
'settings.privacyArtifactsHint': 'Filenames, types, sizes of generated files. File contents are never sent.',
|
||||
'settings.privacyInstallationId': 'Anonymous ID',
|
||||
'settings.privacyOptedOut': 'opted out',
|
||||
'settings.privacyDataDeletion': 'Delete my data',
|
||||
'settings.privacyDataDeletionHint': 'Rotates your anonymous ID and stops sending. Existing traces age out under our retention policy.',
|
||||
'settings.about': 'درباره',
|
||||
'settings.aboutHint': 'جزئیات نسخه و اجرا',
|
||||
'settings.appVersion': 'نسخه',
|
||||
|
|
|
|||
|
|
@ -149,6 +149,23 @@ export const fr: Dict = {
|
|||
'settings.mediaProviderClearConfirm': 'Effacer les paramètres enregistrés pour {name} ? Vous devrez les saisir à nouveau pour utiliser {name}.',
|
||||
'settings.mediaProviderPlaceholder': 'Coller la clé API',
|
||||
'settings.mediaProviderBaseUrlPlaceholder': 'Remplacer l\'URL de base par défaut',
|
||||
'settings.privacy': 'Privacy',
|
||||
'settings.privacyHint': 'What data is shared with the Open Design team',
|
||||
'settings.privacyConsentKicker': 'Help us improve Open Design',
|
||||
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
|
||||
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
|
||||
'settings.privacyConsentShare': 'Help improve',
|
||||
'settings.privacyConsentDecline': 'Not now',
|
||||
'settings.privacyMetrics': 'Anonymous metrics',
|
||||
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
|
||||
'settings.privacyContent': 'Conversation content',
|
||||
'settings.privacyContentHint': "Your prompts and the assistant's responses (truncated 8 KB / 16 KB). API keys, tokens, JWTs, emails, IPs, and credit-card numbers are stripped automatically before send.",
|
||||
'settings.privacyArtifacts': 'Project artifacts manifest',
|
||||
'settings.privacyArtifactsHint': 'Filenames, types, sizes of generated files. File contents are never sent.',
|
||||
'settings.privacyInstallationId': 'Anonymous ID',
|
||||
'settings.privacyOptedOut': 'opted out',
|
||||
'settings.privacyDataDeletion': 'Delete my data',
|
||||
'settings.privacyDataDeletionHint': 'Rotates your anonymous ID and stops sending. Existing traces age out under our retention policy.',
|
||||
'settings.about': 'À propos',
|
||||
'settings.aboutHint': 'Version et informations d\'exécution',
|
||||
'settings.appVersion': 'Version',
|
||||
|
|
|
|||
|
|
@ -149,6 +149,23 @@ export const hu: Dict = {
|
|||
'settings.mediaProviderClearConfirm': 'Törölni szeretnéd a mentett {name} beállításokat? A {name} használatához újra meg kell adnod azokat.',
|
||||
'settings.mediaProviderPlaceholder': 'API-kulcs beillesztése',
|
||||
'settings.mediaProviderBaseUrlPlaceholder': 'Alapértelmezett bázis URL felülírása',
|
||||
'settings.privacy': 'Privacy',
|
||||
'settings.privacyHint': 'What data is shared with the Open Design team',
|
||||
'settings.privacyConsentKicker': 'Help us improve Open Design',
|
||||
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
|
||||
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
|
||||
'settings.privacyConsentShare': 'Help improve',
|
||||
'settings.privacyConsentDecline': 'Not now',
|
||||
'settings.privacyMetrics': 'Anonymous metrics',
|
||||
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
|
||||
'settings.privacyContent': 'Conversation content',
|
||||
'settings.privacyContentHint': "Your prompts and the assistant's responses (truncated 8 KB / 16 KB). API keys, tokens, JWTs, emails, IPs, and credit-card numbers are stripped automatically before send.",
|
||||
'settings.privacyArtifacts': 'Project artifacts manifest',
|
||||
'settings.privacyArtifactsHint': 'Filenames, types, sizes of generated files. File contents are never sent.',
|
||||
'settings.privacyInstallationId': 'Anonymous ID',
|
||||
'settings.privacyOptedOut': 'opted out',
|
||||
'settings.privacyDataDeletion': 'Delete my data',
|
||||
'settings.privacyDataDeletionHint': 'Rotates your anonymous ID and stops sending. Existing traces age out under our retention policy.',
|
||||
'settings.about': 'Névjegy',
|
||||
'settings.aboutHint': 'Verzió- és futtatókörnyezeti adatok',
|
||||
'settings.appVersion': 'Verzió',
|
||||
|
|
|
|||
|
|
@ -145,6 +145,23 @@ export const id: Dict = {
|
|||
'settings.mediaProviderClearConfirm': 'Hapus pengaturan {name} yang tersimpan? Anda perlu memasukkannya lagi untuk menggunakan {name}.',
|
||||
'settings.mediaProviderPlaceholder': 'Tempel API key',
|
||||
'settings.mediaProviderBaseUrlPlaceholder': 'Timpa base URL default',
|
||||
'settings.privacy': 'Privacy',
|
||||
'settings.privacyHint': 'What data is shared with the Open Design team',
|
||||
'settings.privacyConsentKicker': 'Help us improve Open Design',
|
||||
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
|
||||
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
|
||||
'settings.privacyConsentShare': 'Help improve',
|
||||
'settings.privacyConsentDecline': 'Not now',
|
||||
'settings.privacyMetrics': 'Anonymous metrics',
|
||||
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
|
||||
'settings.privacyContent': 'Conversation content',
|
||||
'settings.privacyContentHint': "Your prompts and the assistant's responses (truncated 8 KB / 16 KB). API keys, tokens, JWTs, emails, IPs, and credit-card numbers are stripped automatically before send.",
|
||||
'settings.privacyArtifacts': 'Project artifacts manifest',
|
||||
'settings.privacyArtifactsHint': 'Filenames, types, sizes of generated files. File contents are never sent.',
|
||||
'settings.privacyInstallationId': 'Anonymous ID',
|
||||
'settings.privacyOptedOut': 'opted out',
|
||||
'settings.privacyDataDeletion': 'Delete my data',
|
||||
'settings.privacyDataDeletionHint': 'Rotates your anonymous ID and stops sending. Existing traces age out under our retention policy.',
|
||||
'settings.about': 'Tentang',
|
||||
'settings.aboutHint': 'Detail versi dan runtime',
|
||||
'settings.appVersion': 'Versi',
|
||||
|
|
|
|||
|
|
@ -149,6 +149,23 @@ export const ja: Dict = {
|
|||
'settings.mediaProviderClearConfirm': '保存された {name} の設定を削除しますか?{name} を使うには再度入力する必要があります。',
|
||||
'settings.mediaProviderPlaceholder': 'APIキーを貼り付け',
|
||||
'settings.mediaProviderBaseUrlPlaceholder': 'デフォルトのベース URL を上書き',
|
||||
'settings.privacy': 'Privacy',
|
||||
'settings.privacyHint': 'What data is shared with the Open Design team',
|
||||
'settings.privacyConsentKicker': 'Help us improve Open Design',
|
||||
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
|
||||
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
|
||||
'settings.privacyConsentShare': 'Help improve',
|
||||
'settings.privacyConsentDecline': 'Not now',
|
||||
'settings.privacyMetrics': 'Anonymous metrics',
|
||||
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
|
||||
'settings.privacyContent': 'Conversation content',
|
||||
'settings.privacyContentHint': "Your prompts and the assistant's responses (truncated 8 KB / 16 KB). API keys, tokens, JWTs, emails, IPs, and credit-card numbers are stripped automatically before send.",
|
||||
'settings.privacyArtifacts': 'Project artifacts manifest',
|
||||
'settings.privacyArtifactsHint': 'Filenames, types, sizes of generated files. File contents are never sent.',
|
||||
'settings.privacyInstallationId': 'Anonymous ID',
|
||||
'settings.privacyOptedOut': 'opted out',
|
||||
'settings.privacyDataDeletion': 'Delete my data',
|
||||
'settings.privacyDataDeletionHint': 'Rotates your anonymous ID and stops sending. Existing traces age out under our retention policy.',
|
||||
'settings.about': 'About',
|
||||
'settings.aboutHint': 'バージョンと実行環境の詳細',
|
||||
'settings.appVersion': 'バージョン',
|
||||
|
|
|
|||
|
|
@ -149,6 +149,23 @@ export const ko: Dict = {
|
|||
'settings.mediaProviderClearConfirm': '저장된 {name} 설정을 삭제하시겠습니까? {name}을(를) 사용하려면 다시 입력해야 합니다.',
|
||||
'settings.mediaProviderPlaceholder': 'API 키를 붙여넣으세요',
|
||||
'settings.mediaProviderBaseUrlPlaceholder': '기본 Base URL 재정의',
|
||||
'settings.privacy': 'Privacy',
|
||||
'settings.privacyHint': 'What data is shared with the Open Design team',
|
||||
'settings.privacyConsentKicker': 'Help us improve Open Design',
|
||||
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
|
||||
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
|
||||
'settings.privacyConsentShare': 'Help improve',
|
||||
'settings.privacyConsentDecline': 'Not now',
|
||||
'settings.privacyMetrics': 'Anonymous metrics',
|
||||
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
|
||||
'settings.privacyContent': 'Conversation content',
|
||||
'settings.privacyContentHint': "Your prompts and the assistant's responses (truncated 8 KB / 16 KB). API keys, tokens, JWTs, emails, IPs, and credit-card numbers are stripped automatically before send.",
|
||||
'settings.privacyArtifacts': 'Project artifacts manifest',
|
||||
'settings.privacyArtifactsHint': 'Filenames, types, sizes of generated files. File contents are never sent.',
|
||||
'settings.privacyInstallationId': 'Anonymous ID',
|
||||
'settings.privacyOptedOut': 'opted out',
|
||||
'settings.privacyDataDeletion': 'Delete my data',
|
||||
'settings.privacyDataDeletionHint': 'Rotates your anonymous ID and stops sending. Existing traces age out under our retention policy.',
|
||||
'settings.about': '정보',
|
||||
'settings.aboutHint': '버전 및 런타임 세부 정보',
|
||||
'settings.appVersion': '버전',
|
||||
|
|
|
|||
|
|
@ -149,6 +149,23 @@ export const pl: Dict = {
|
|||
'settings.mediaProviderClearConfirm': 'Usunąć zapisane ustawienia dla {name}? Aby ponownie używać {name}, musisz wprowadzić je ponownie.',
|
||||
'settings.mediaProviderPlaceholder': 'Wklej klucz API',
|
||||
'settings.mediaProviderBaseUrlPlaceholder': 'Nadpisz domyślny bazowy URL',
|
||||
'settings.privacy': 'Privacy',
|
||||
'settings.privacyHint': 'What data is shared with the Open Design team',
|
||||
'settings.privacyConsentKicker': 'Help us improve Open Design',
|
||||
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
|
||||
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
|
||||
'settings.privacyConsentShare': 'Help improve',
|
||||
'settings.privacyConsentDecline': 'Not now',
|
||||
'settings.privacyMetrics': 'Anonymous metrics',
|
||||
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
|
||||
'settings.privacyContent': 'Conversation content',
|
||||
'settings.privacyContentHint': "Your prompts and the assistant's responses (truncated 8 KB / 16 KB). API keys, tokens, JWTs, emails, IPs, and credit-card numbers are stripped automatically before send.",
|
||||
'settings.privacyArtifacts': 'Project artifacts manifest',
|
||||
'settings.privacyArtifactsHint': 'Filenames, types, sizes of generated files. File contents are never sent.',
|
||||
'settings.privacyInstallationId': 'Anonymous ID',
|
||||
'settings.privacyOptedOut': 'opted out',
|
||||
'settings.privacyDataDeletion': 'Delete my data',
|
||||
'settings.privacyDataDeletionHint': 'Rotates your anonymous ID and stops sending. Existing traces age out under our retention policy.',
|
||||
'settings.about': 'O aplikacji',
|
||||
'settings.aboutHint': 'Szczegóły wersji i środowiska uruchomieniowego',
|
||||
'settings.appVersion': 'Wersja',
|
||||
|
|
|
|||
|
|
@ -146,6 +146,23 @@ export const ptBR: Dict = {
|
|||
'settings.mediaProviderClearConfirm': 'Remover as configurações salvas de {name}? Você precisará inseri-las novamente para usar {name}.',
|
||||
'settings.mediaProviderPlaceholder': 'Cole a API key',
|
||||
'settings.mediaProviderBaseUrlPlaceholder': 'Sobrescrever Base URL padrão',
|
||||
'settings.privacy': 'Privacy',
|
||||
'settings.privacyHint': 'What data is shared with the Open Design team',
|
||||
'settings.privacyConsentKicker': 'Help us improve Open Design',
|
||||
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
|
||||
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
|
||||
'settings.privacyConsentShare': 'Help improve',
|
||||
'settings.privacyConsentDecline': 'Not now',
|
||||
'settings.privacyMetrics': 'Anonymous metrics',
|
||||
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
|
||||
'settings.privacyContent': 'Conversation content',
|
||||
'settings.privacyContentHint': "Your prompts and the assistant's responses (truncated 8 KB / 16 KB). API keys, tokens, JWTs, emails, IPs, and credit-card numbers are stripped automatically before send.",
|
||||
'settings.privacyArtifacts': 'Project artifacts manifest',
|
||||
'settings.privacyArtifactsHint': 'Filenames, types, sizes of generated files. File contents are never sent.',
|
||||
'settings.privacyInstallationId': 'Anonymous ID',
|
||||
'settings.privacyOptedOut': 'opted out',
|
||||
'settings.privacyDataDeletion': 'Delete my data',
|
||||
'settings.privacyDataDeletionHint': 'Rotates your anonymous ID and stops sending. Existing traces age out under our retention policy.',
|
||||
'settings.about': 'Sobre',
|
||||
'settings.aboutHint': 'Versão e detalhes de execução',
|
||||
'settings.appVersion': 'Versão',
|
||||
|
|
|
|||
|
|
@ -146,6 +146,23 @@ export const ru: Dict = {
|
|||
'settings.mediaProviderClearConfirm': 'Удалить сохранённые настройки {name}? Вам придётся ввести их заново, чтобы использовать {name}.',
|
||||
'settings.mediaProviderPlaceholder': 'Вставьте API-ключ',
|
||||
'settings.mediaProviderBaseUrlPlaceholder': 'Переопределить базовый URL',
|
||||
'settings.privacy': 'Privacy',
|
||||
'settings.privacyHint': 'What data is shared with the Open Design team',
|
||||
'settings.privacyConsentKicker': 'Help us improve Open Design',
|
||||
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
|
||||
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
|
||||
'settings.privacyConsentShare': 'Help improve',
|
||||
'settings.privacyConsentDecline': 'Not now',
|
||||
'settings.privacyMetrics': 'Anonymous metrics',
|
||||
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
|
||||
'settings.privacyContent': 'Conversation content',
|
||||
'settings.privacyContentHint': "Your prompts and the assistant's responses (truncated 8 KB / 16 KB). API keys, tokens, JWTs, emails, IPs, and credit-card numbers are stripped automatically before send.",
|
||||
'settings.privacyArtifacts': 'Project artifacts manifest',
|
||||
'settings.privacyArtifactsHint': 'Filenames, types, sizes of generated files. File contents are never sent.',
|
||||
'settings.privacyInstallationId': 'Anonymous ID',
|
||||
'settings.privacyOptedOut': 'opted out',
|
||||
'settings.privacyDataDeletion': 'Delete my data',
|
||||
'settings.privacyDataDeletionHint': 'Rotates your anonymous ID and stops sending. Existing traces age out under our retention policy.',
|
||||
'settings.about': 'О приложении',
|
||||
'settings.aboutHint': 'Версия и сведения о запуске',
|
||||
'settings.appVersion': 'Версия',
|
||||
|
|
|
|||
|
|
@ -143,6 +143,23 @@ export const tr: Dict = {
|
|||
'settings.mediaProviderClearConfirm': 'Kayıtlı {name} ayarları silinsin mi? {name}\'ı tekrar kullanmak için bunları yeniden girmeniz gerekecek.',
|
||||
'settings.mediaProviderPlaceholder': 'API anahtarı yapıştır',
|
||||
'settings.mediaProviderBaseUrlPlaceholder': 'Varsayılan temel URL’yi görmezden gel',
|
||||
'settings.privacy': 'Privacy',
|
||||
'settings.privacyHint': 'What data is shared with the Open Design team',
|
||||
'settings.privacyConsentKicker': 'Help us improve Open Design',
|
||||
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
|
||||
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
|
||||
'settings.privacyConsentShare': 'Help improve',
|
||||
'settings.privacyConsentDecline': 'Not now',
|
||||
'settings.privacyMetrics': 'Anonymous metrics',
|
||||
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
|
||||
'settings.privacyContent': 'Conversation content',
|
||||
'settings.privacyContentHint': "Your prompts and the assistant's responses (truncated 8 KB / 16 KB). API keys, tokens, JWTs, emails, IPs, and credit-card numbers are stripped automatically before send.",
|
||||
'settings.privacyArtifacts': 'Project artifacts manifest',
|
||||
'settings.privacyArtifactsHint': 'Filenames, types, sizes of generated files. File contents are never sent.',
|
||||
'settings.privacyInstallationId': 'Anonymous ID',
|
||||
'settings.privacyOptedOut': 'opted out',
|
||||
'settings.privacyDataDeletion': 'Delete my data',
|
||||
'settings.privacyDataDeletionHint': 'Rotates your anonymous ID and stops sending. Existing traces age out under our retention policy.',
|
||||
'settings.about': 'Hakkında',
|
||||
'settings.aboutHint': 'Sürüm ve çalışma zamanı detayları',
|
||||
'settings.appVersion': 'Sürüm',
|
||||
|
|
|
|||
|
|
@ -148,6 +148,23 @@ export const uk: Dict = {
|
|||
'settings.mediaProviderClearConfirm': 'Видалити збережені налаштування {name}? Вам доведеться ввести їх знову, щоб використовувати {name}.',
|
||||
'settings.mediaProviderPlaceholder': 'Вставте API-ключ',
|
||||
'settings.mediaProviderBaseUrlPlaceholder': 'Переопрацювати базовий URL за замовчуванням',
|
||||
'settings.privacy': 'Privacy',
|
||||
'settings.privacyHint': 'What data is shared with the Open Design team',
|
||||
'settings.privacyConsentKicker': 'Help us improve Open Design',
|
||||
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
|
||||
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
|
||||
'settings.privacyConsentShare': 'Help improve',
|
||||
'settings.privacyConsentDecline': 'Not now',
|
||||
'settings.privacyMetrics': 'Anonymous metrics',
|
||||
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
|
||||
'settings.privacyContent': 'Conversation content',
|
||||
'settings.privacyContentHint': "Your prompts and the assistant's responses (truncated 8 KB / 16 KB). API keys, tokens, JWTs, emails, IPs, and credit-card numbers are stripped automatically before send.",
|
||||
'settings.privacyArtifacts': 'Project artifacts manifest',
|
||||
'settings.privacyArtifactsHint': 'Filenames, types, sizes of generated files. File contents are never sent.',
|
||||
'settings.privacyInstallationId': 'Anonymous ID',
|
||||
'settings.privacyOptedOut': 'opted out',
|
||||
'settings.privacyDataDeletion': 'Delete my data',
|
||||
'settings.privacyDataDeletionHint': 'Rotates your anonymous ID and stops sending. Existing traces age out under our retention policy.',
|
||||
'settings.about': 'Про програму',
|
||||
'settings.aboutHint': 'Деталі версії та виконання',
|
||||
'settings.appVersion': 'Версія',
|
||||
|
|
|
|||
|
|
@ -145,6 +145,23 @@ export const zhCN: Dict = {
|
|||
'settings.mediaProviderClearConfirm': '清除已保存的 {name} 设置?您需要再次输入才能使用 {name}。',
|
||||
'settings.mediaProviderPlaceholder': '粘贴 API key',
|
||||
'settings.mediaProviderBaseUrlPlaceholder': '覆盖默认 Base URL',
|
||||
'settings.privacy': '隐私',
|
||||
'settings.privacyHint': '与 Open Design 团队共享哪些数据',
|
||||
'settings.privacyConsentKicker': '帮助我们改进 Open Design',
|
||||
'settings.privacyConsentLead': 'Open Design 可以将使用数据共享给我们的团队以协助改进。包括:',
|
||||
'settings.privacyConsentFooter': '你可以随时在 设置 → 隐私 中修改任意一项。我们绝不上传你生成的产物文件内容。',
|
||||
'settings.privacyConsentShare': '帮助改进',
|
||||
'settings.privacyConsentDecline': '暂不',
|
||||
'settings.privacyMetrics': '匿名指标',
|
||||
'settings.privacyMetricsHint': '运行次数、token 用量、错误率、时长。不包含 prompt,不包含项目数据。',
|
||||
'settings.privacyContent': '对话内容',
|
||||
'settings.privacyContentHint': '你发送的 prompt 与助手的回复(分别截断到 8 KB / 16 KB)。API key、token、JWT、邮箱、IP 与信用卡号在发送前会自动剥离。',
|
||||
'settings.privacyArtifacts': '项目产物清单',
|
||||
'settings.privacyArtifactsHint': '生成文件的名称、类型、大小。文件内容绝不发送。',
|
||||
'settings.privacyInstallationId': '匿名 ID',
|
||||
'settings.privacyOptedOut': '已退出',
|
||||
'settings.privacyDataDeletion': '删除我的数据',
|
||||
'settings.privacyDataDeletionHint': '轮换你的匿名 ID 并停止发送。已有数据按我们的留存策略自然过期。',
|
||||
'settings.about': '关于',
|
||||
'settings.aboutHint': '版本和运行时详情',
|
||||
'settings.appVersion': '版本',
|
||||
|
|
|
|||
|
|
@ -145,6 +145,23 @@ export const zhTW: Dict = {
|
|||
'settings.mediaProviderClearConfirm': '清除已儲存的 {name} 設定?您需要再次輸入才能使用 {name}。',
|
||||
'settings.mediaProviderPlaceholder': '貼上 API key',
|
||||
'settings.mediaProviderBaseUrlPlaceholder': '覆蓋預設 Base URL',
|
||||
'settings.privacy': '隱私',
|
||||
'settings.privacyHint': '與 Open Design 團隊共享哪些資料',
|
||||
'settings.privacyConsentKicker': '協助我們改進 Open Design',
|
||||
'settings.privacyConsentLead': 'Open Design 可以將使用資料分享給我們的團隊以協助改進。包含:',
|
||||
'settings.privacyConsentFooter': '你可以隨時在 設定 → 隱私 中變更任一項。我們絕不上傳你產生的產出檔案內容。',
|
||||
'settings.privacyConsentShare': '協助改進',
|
||||
'settings.privacyConsentDecline': '暫不',
|
||||
'settings.privacyMetrics': '匿名指標',
|
||||
'settings.privacyMetricsHint': '執行次數、token 用量、錯誤率、時長。不包含 prompt,不包含專案資料。',
|
||||
'settings.privacyContent': '對話內容',
|
||||
'settings.privacyContentHint': '你送出的 prompt 與助理的回覆(分別截斷至 8 KB / 16 KB)。API key、token、JWT、信箱、IP 與信用卡號會在傳送前自動剝離。',
|
||||
'settings.privacyArtifacts': '專案產出清單',
|
||||
'settings.privacyArtifactsHint': '產生檔案的名稱、類型、大小。檔案內容絕不傳送。',
|
||||
'settings.privacyInstallationId': '匿名 ID',
|
||||
'settings.privacyOptedOut': '已退出',
|
||||
'settings.privacyDataDeletion': '刪除我的資料',
|
||||
'settings.privacyDataDeletionHint': '輪換你的匿名 ID 並停止傳送。既有資料依保留政策自然過期。',
|
||||
'settings.about': '關於',
|
||||
'settings.aboutHint': '版本與執行環境詳情',
|
||||
'settings.appVersion': '版本',
|
||||
|
|
|
|||
|
|
@ -168,6 +168,23 @@ export interface Dict {
|
|||
'settings.mediaProviderClearConfirm': string;
|
||||
'settings.mediaProviderPlaceholder': string;
|
||||
'settings.mediaProviderBaseUrlPlaceholder': string;
|
||||
'settings.privacy': string;
|
||||
'settings.privacyHint': string;
|
||||
'settings.privacyConsentKicker': string;
|
||||
'settings.privacyConsentLead': string;
|
||||
'settings.privacyConsentFooter': string;
|
||||
'settings.privacyConsentShare': string;
|
||||
'settings.privacyConsentDecline': string;
|
||||
'settings.privacyMetrics': string;
|
||||
'settings.privacyMetricsHint': string;
|
||||
'settings.privacyContent': string;
|
||||
'settings.privacyContentHint': string;
|
||||
'settings.privacyArtifacts': string;
|
||||
'settings.privacyArtifactsHint': string;
|
||||
'settings.privacyInstallationId': string;
|
||||
'settings.privacyOptedOut': string;
|
||||
'settings.privacyDataDeletion': string;
|
||||
'settings.privacyDataDeletionHint': string;
|
||||
'settings.about': string;
|
||||
'settings.aboutHint': string;
|
||||
'settings.appVersion': string;
|
||||
|
|
|
|||
|
|
@ -14273,6 +14273,98 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Privacy panel layout. The toggle row stack reuses .toggle-row (the
|
||||
iOS-style switch from NewProjectPanel); we only need to space the rows
|
||||
apart. The disclosure list inside the consent card is purely a typography
|
||||
block — no chrome of its own. */
|
||||
.settings-privacy-toggles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-privacy-disclosure {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.settings-privacy-disclosure > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.settings-privacy-disclosure dt {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-privacy-disclosure dd {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* First-run privacy consent banner. Cookie-consent-style: anchored to the
|
||||
bottom-right of the viewport, prominent but non-blocking, equal-weight
|
||||
accept / decline pair via the existing 2-cell seg-control. Reuses the
|
||||
same typography + radii + shadow as the rest of the panels. */
|
||||
.privacy-consent-banner {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
/* Below modal backdrops; App.tsx hides the banner while Settings is open. */
|
||||
z-index: 90;
|
||||
width: 380px;
|
||||
max-width: calc(100vw - 32px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 18px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.privacy-consent-banner .seg-control {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.privacy-consent-banner-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.privacy-consent-banner-head .kicker {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.privacy-consent-banner-head h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.privacy-consent-banner-lead {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.privacy-consent-banner-footer {
|
||||
margin: 2px 0 6px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
External MCP servers — Settings panel
|
||||
------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -23,8 +23,33 @@ import type {
|
|||
SseErrorPayload,
|
||||
} from '@open-design/contracts';
|
||||
import type { StreamHandlers } from './anthropic';
|
||||
|
||||
/**
|
||||
* Returns the front-end carrier that's about to send this request:
|
||||
* - 'desktop' when running inside the Electron shell
|
||||
* - 'web' when running in a regular browser
|
||||
* - 'unknown' in non-browser test environments (jsdom without a UA)
|
||||
*
|
||||
* The daemon uses this to label telemetry traces. Cheap, called once per
|
||||
* run so caching isn't worth the complexity.
|
||||
*/
|
||||
function detectClientType(): 'desktop' | 'web' | 'unknown' {
|
||||
if (typeof navigator === 'undefined') return 'unknown';
|
||||
const ua = navigator.userAgent ?? '';
|
||||
if (ua.includes('Electron/')) return 'desktop';
|
||||
if (ua) return 'web';
|
||||
return 'unknown';
|
||||
}
|
||||
import { parseSseFrame } from './sse';
|
||||
|
||||
export function latestUserPromptFromHistory(history: ChatMessage[]): string {
|
||||
for (let i = history.length - 1; i >= 0; i -= 1) {
|
||||
const message = history[i];
|
||||
if (message?.role === 'user') return message.content;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export interface DaemonStreamHandlers extends StreamHandlers {
|
||||
onAgentEvent: (ev: AgentEvent) => void;
|
||||
}
|
||||
|
|
@ -106,6 +131,7 @@ export async function streamViaDaemon({
|
|||
const request: ChatRequest = {
|
||||
agentId,
|
||||
message: transcript,
|
||||
currentPrompt: latestUserPromptFromHistory(history),
|
||||
projectId: projectId ?? null,
|
||||
conversationId: conversationId ?? null,
|
||||
assistantMessageId: assistantMessageId ?? null,
|
||||
|
|
@ -123,7 +149,14 @@ export async function streamViaDaemon({
|
|||
try {
|
||||
const createResp = await fetch('/api/runs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Tells the daemon which front-end carrier started the run so the
|
||||
// telemetry trace can be tagged 'client:desktop' vs 'client:web'.
|
||||
// The daemon falls back to a User-Agent sniff when this header is
|
||||
// absent (e.g. third-party clients), so omitting it in tests is OK.
|
||||
'X-OD-Client': detectClientType(),
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -254,6 +254,13 @@ export function loadConfig(): AppConfig {
|
|||
};
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<AppConfig>;
|
||||
// Strip daemon-owned privacy fields if a stale localStorage payload
|
||||
// still carries them. Older builds wrote these to localStorage; we
|
||||
// now treat the daemon as authoritative so the user can rotate /
|
||||
// revoke without leaving residue in browser storage.
|
||||
for (const key of DAEMON_OWNED_KEYS) {
|
||||
delete (parsed as Record<string, unknown>)[key];
|
||||
}
|
||||
const parsedHasApiProtocol = Object.prototype.hasOwnProperty.call(
|
||||
parsed,
|
||||
'apiProtocol',
|
||||
|
|
@ -341,8 +348,23 @@ export async function syncComposioConfigToDaemon(
|
|||
}
|
||||
}
|
||||
|
||||
// Privacy-sensitive fields the user can revoke. We deliberately keep
|
||||
// these out of localStorage so the daemon remains the single source of
|
||||
// truth: clearing app-config.json (or rotating via "Delete my data")
|
||||
// fully resets the install identity, with no residual cohort key
|
||||
// silently sitting in browser storage where the user can't see it.
|
||||
const DAEMON_OWNED_KEYS = new Set<keyof AppConfig>([
|
||||
'installationId',
|
||||
'telemetry',
|
||||
'privacyDecisionAt',
|
||||
]);
|
||||
|
||||
export function saveConfig(config: AppConfig): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
|
||||
const sanitized: AppConfig = { ...config };
|
||||
for (const key of DAEMON_OWNED_KEYS) {
|
||||
delete (sanitized as unknown as Record<string, unknown>)[key];
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(sanitized));
|
||||
}
|
||||
|
||||
export function mergeDaemonConfig(
|
||||
|
|
@ -380,6 +402,23 @@ export function mergeDaemonConfig(
|
|||
if (daemonConfig.orbit !== undefined) {
|
||||
next.orbit = normalizeOrbit(daemonConfig.orbit);
|
||||
}
|
||||
if (daemonConfig.installationId !== undefined) {
|
||||
next.installationId = daemonConfig.installationId;
|
||||
}
|
||||
if (daemonConfig.telemetry !== undefined) {
|
||||
next.telemetry = { ...daemonConfig.telemetry };
|
||||
}
|
||||
if (daemonConfig.privacyDecisionAt !== undefined) {
|
||||
next.privacyDecisionAt = daemonConfig.privacyDecisionAt;
|
||||
} else if (
|
||||
daemonConfig.installationId !== undefined ||
|
||||
daemonConfig.telemetry !== undefined
|
||||
) {
|
||||
// One-shot migration for configs created before privacyDecisionAt
|
||||
// existed. If the daemon already has an id or telemetry prefs, the user
|
||||
// has resolved the first-run prompt and should not see it again.
|
||||
next.privacyDecisionAt = Date.now();
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
|
|
@ -435,6 +474,9 @@ export async function syncConfigToDaemon(
|
|||
disabledSkills: config.disabledSkills,
|
||||
disabledDesignSystems: config.disabledDesignSystems,
|
||||
orbit: normalizeOrbit(config.orbit),
|
||||
installationId: config.installationId,
|
||||
telemetry: config.telemetry,
|
||||
privacyDecisionAt: config.privacyDecisionAt,
|
||||
};
|
||||
try {
|
||||
const response = await fetch('/api/app-config', {
|
||||
|
|
|
|||
|
|
@ -278,18 +278,26 @@ export async function listMessages(
|
|||
}
|
||||
}
|
||||
|
||||
export interface SaveMessageOptions {
|
||||
telemetryFinalized?: boolean;
|
||||
}
|
||||
|
||||
export async function saveMessage(
|
||||
projectId: string,
|
||||
conversationId: string,
|
||||
message: ChatMessage,
|
||||
options: SaveMessageOptions = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
const body = options.telemetryFinalized
|
||||
? { ...message, telemetryFinalized: true }
|
||||
: message;
|
||||
await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}/messages/${encodeURIComponent(message.id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message),
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -301,6 +301,25 @@ export interface AppConfig {
|
|||
// IDs of skills/design-systems the user has explicitly disabled.
|
||||
disabledSkills?: string[];
|
||||
disabledDesignSystems?: string[];
|
||||
// Anonymous install identifier for telemetry. Generated locally the first
|
||||
// time a user opts in via Settings → Privacy. `null` after the user
|
||||
// explicitly opts out (or rotates "Delete my data"); `undefined` when the
|
||||
// daemon has not assigned an anonymous id yet.
|
||||
installationId?: string | null;
|
||||
// Unix-millis timestamp recording that the first-run privacy prompt was
|
||||
// resolved. This is independent from installationId so Delete my data can
|
||||
// rotate or clear the anonymous id without re-opening the consent banner.
|
||||
privacyDecisionAt?: number | null;
|
||||
// Privacy preferences governing what (if anything) is shipped to the
|
||||
// Langfuse-backed telemetry endpoint. All three default to off until the
|
||||
// user makes an explicit choice.
|
||||
telemetry?: TelemetryConfig;
|
||||
}
|
||||
|
||||
export interface TelemetryConfig {
|
||||
metrics?: boolean;
|
||||
content?: boolean;
|
||||
artifactManifest?: boolean;
|
||||
}
|
||||
|
||||
export interface ComposioSettings {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
import { App } from '../../src/App';
|
||||
import type { AppConfig } from '../../src/types';
|
||||
import {
|
||||
fetchDaemonConfig,
|
||||
fetchComposioConfigFromDaemon,
|
||||
loadConfig,
|
||||
mergeDaemonConfig,
|
||||
|
|
@ -126,6 +127,7 @@ vi.mock('../../src/state/config', async () => {
|
|||
loadConfig: vi.fn(),
|
||||
mergeDaemonConfig: vi.fn(),
|
||||
saveConfig: vi.fn(),
|
||||
fetchDaemonConfig: vi.fn().mockResolvedValue({}),
|
||||
syncConfigToDaemon: vi.fn().mockResolvedValue(undefined),
|
||||
syncComposioConfigToDaemon: vi.fn().mockResolvedValue(true),
|
||||
fetchComposioConfigFromDaemon: vi.fn().mockResolvedValue(null),
|
||||
|
|
@ -140,6 +142,7 @@ const mockedFetchPromptTemplates = vi.mocked(fetchPromptTemplates);
|
|||
const mockedFetchSkills = vi.mocked(fetchSkills);
|
||||
const mockedListProjects = vi.mocked(listProjects);
|
||||
const mockedListTemplates = vi.mocked(listTemplates);
|
||||
const mockedFetchDaemonConfig = vi.mocked(fetchDaemonConfig);
|
||||
const mockedFetchComposioConfigFromDaemon = vi.mocked(fetchComposioConfigFromDaemon);
|
||||
const mockedLoadConfig = vi.mocked(loadConfig);
|
||||
const mockedMergeDaemonConfig = vi.mocked(mergeDaemonConfig);
|
||||
|
|
@ -176,6 +179,7 @@ describe('App connectors settings flows', () => {
|
|||
mockedFetchAppVersionInfo.mockResolvedValue(null);
|
||||
mockedListProjects.mockResolvedValue([]);
|
||||
mockedListTemplates.mockResolvedValue([]);
|
||||
mockedFetchDaemonConfig.mockResolvedValue({});
|
||||
mockedFetchComposioConfigFromDaemon.mockResolvedValue(null);
|
||||
mockedMergeDaemonConfig.mockImplementation((local) => local);
|
||||
mockedLoadConfig.mockReturnValue({ ...baseConfig });
|
||||
|
|
@ -209,6 +213,43 @@ describe('App connectors settings flows', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not show first-run privacy consent until daemon config hydration finishes', async () => {
|
||||
let resolveDaemonConfig: (value: Record<string, never>) => void = () => {};
|
||||
mockedFetchDaemonConfig.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveDaemonConfig = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedFetchDaemonConfig).toHaveBeenCalled();
|
||||
});
|
||||
expect(container.querySelector('.privacy-consent-banner')).toBeNull();
|
||||
|
||||
resolveDaemonConfig({});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.privacy-consent-banner')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides first-run privacy consent while settings is open', async () => {
|
||||
const { container } = render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.privacy-consent-banner')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open connectors settings' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog', { name: 'Settings dialog' })).toBeTruthy();
|
||||
});
|
||||
expect(container.querySelector('.privacy-consent-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('normalizes local persistence but sends the raw replacement key to the daemon on save', async () => {
|
||||
mockedLoadConfig.mockReturnValue({
|
||||
...baseConfig,
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ describe('App media provider sync flows', () => {
|
|||
mockedLoadConfig.mockReturnValue({
|
||||
...baseConfig,
|
||||
onboardingCompleted: false,
|
||||
privacyDecisionAt: 1778244000000,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
|
|
|||
64
apps/web/tests/components/PrivacySection.test.tsx
Normal file
64
apps/web/tests/components/PrivacySection.test.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { PrivacySection } from '../../src/components/PrivacySection';
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
import type { AppConfig } from '../../src/types';
|
||||
|
||||
const baseConfig: AppConfig = {
|
||||
mode: 'api',
|
||||
apiKey: '',
|
||||
apiProtocol: 'anthropic',
|
||||
apiVersion: '',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
model: 'claude-sonnet-4-5',
|
||||
apiProviderBaseUrl: 'https://api.anthropic.com',
|
||||
apiProtocolConfigs: {},
|
||||
agentId: null,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
onboardingCompleted: true,
|
||||
mediaProviders: {},
|
||||
agentModels: {},
|
||||
agentCliEnv: {},
|
||||
};
|
||||
|
||||
function Harness({ initial }: { initial: AppConfig }) {
|
||||
const [cfg, setCfg] = useState(initial);
|
||||
return (
|
||||
<I18nProvider initial="en">
|
||||
<PrivacySection cfg={cfg} setCfg={setCfg} />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('PrivacySection', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('regenerates an installation id when telemetry is re-enabled after opt-out', () => {
|
||||
vi.stubGlobal('crypto', { randomUUID: vi.fn(() => 'inst-new') });
|
||||
|
||||
render(
|
||||
<Harness
|
||||
initial={{
|
||||
...baseConfig,
|
||||
installationId: null,
|
||||
privacyDecisionAt: 1778244000000,
|
||||
telemetry: { metrics: false, content: false, artifactManifest: false },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect((screen.getByLabelText('Anonymous ID') as HTMLInputElement).value).toBe('opted out');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Anonymous metrics/ }));
|
||||
|
||||
expect((screen.getByLabelText('Anonymous ID') as HTMLInputElement).value).toBe('inst-new');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { reattachDaemonRun, streamViaDaemon } from '../../src/providers/daemon';
|
||||
import {
|
||||
latestUserPromptFromHistory,
|
||||
reattachDaemonRun,
|
||||
streamViaDaemon,
|
||||
} from '../../src/providers/daemon';
|
||||
import { streamMessageOpenAI } from '../../src/providers/openai-compatible';
|
||||
import { parseSseFrame } from '../../src/providers/sse';
|
||||
|
||||
|
|
@ -31,6 +35,47 @@ describe('parseSseFrame', () => {
|
|||
});
|
||||
|
||||
describe('streamViaDaemon', () => {
|
||||
it('sends the latest user turn separately from the full CLI transcript', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/runs') return jsonResponse({ runId: 'run-1' });
|
||||
if (url === '/api/runs/run-1/events') {
|
||||
return sseResponse('event: end\ndata: {"code":0,"status":"succeeded"}\n\n');
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [
|
||||
{ id: '1', role: 'user', content: 'pre-consent brief' },
|
||||
{ id: '2', role: 'assistant', content: 'draft response' },
|
||||
{ id: '3', role: 'user', content: 'post-consent revision' },
|
||||
],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
const [, createRunInit] = fetchMock.mock.calls[0] as unknown as [RequestInfo | URL, RequestInit];
|
||||
const body = JSON.parse(String(createRunInit.body));
|
||||
expect(body.message).toContain('pre-consent brief');
|
||||
expect(body.message).toContain('post-consent revision');
|
||||
expect(body.currentPrompt).toBe('post-consent revision');
|
||||
});
|
||||
|
||||
it('extracts only the latest user prompt for telemetry', () => {
|
||||
expect(
|
||||
latestUserPromptFromHistory([
|
||||
{ id: '1', role: 'user', content: 'first turn' },
|
||||
{ id: '2', role: 'assistant', content: 'answer' },
|
||||
{ id: '3', role: 'user', content: 'current turn' },
|
||||
]),
|
||||
).toBe('current turn');
|
||||
});
|
||||
|
||||
it('ignores comment frames without notifying handlers', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
DEFAULT_CONFIG,
|
||||
loadConfig,
|
||||
mergeDaemonConfig,
|
||||
saveConfig,
|
||||
syncComposioConfigToDaemon,
|
||||
syncConfigToDaemon,
|
||||
syncMediaProvidersToDaemon,
|
||||
|
|
@ -96,6 +97,28 @@ describe('syncConfigToDaemon', () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('syncs daemon-owned privacy decision fields', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response('{}', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await syncConfigToDaemon({
|
||||
...DEFAULT_CONFIG,
|
||||
installationId: 'install-1',
|
||||
privacyDecisionAt: 1778244000000,
|
||||
telemetry: { metrics: true, content: true, artifactManifest: false },
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(JSON.parse(String(init.body))).toMatchObject({
|
||||
installationId: 'install-1',
|
||||
privacyDecisionAt: 1778244000000,
|
||||
telemetry: { metrics: true, content: true, artifactManifest: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncMediaProvidersToDaemon', () => {
|
||||
|
|
@ -150,6 +173,28 @@ describe('mergeDaemonConfig', () => {
|
|||
codex: { CODEX_HOME: '~/.codex-new', CODEX_BIN: '~/bin/codex-new' },
|
||||
});
|
||||
});
|
||||
|
||||
it('copies privacyDecisionAt from daemon config', () => {
|
||||
const merged = mergeDaemonConfig(DEFAULT_CONFIG, {
|
||||
installationId: 'install-1',
|
||||
privacyDecisionAt: 1778244000000,
|
||||
telemetry: { metrics: true },
|
||||
});
|
||||
|
||||
expect(merged.installationId).toBe('install-1');
|
||||
expect(merged.privacyDecisionAt).toBe(1778244000000);
|
||||
expect(merged.telemetry).toEqual({ metrics: true });
|
||||
});
|
||||
|
||||
it('migrates old daemon privacy config to a resolved decision', () => {
|
||||
const merged = mergeDaemonConfig(DEFAULT_CONFIG, {
|
||||
installationId: 'install-1',
|
||||
telemetry: { metrics: true },
|
||||
});
|
||||
|
||||
expect(merged.installationId).toBe('install-1');
|
||||
expect(typeof merged.privacyDecisionAt).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -299,3 +344,19 @@ describe('loadConfig', () => {
|
|||
expect(DEFAULT_CONFIG.configMigrationVersion).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveConfig', () => {
|
||||
it('keeps daemon-owned privacy fields out of localStorage', () => {
|
||||
saveConfig({
|
||||
...DEFAULT_CONFIG,
|
||||
installationId: 'install-1',
|
||||
privacyDecisionAt: 1778244000000,
|
||||
telemetry: { metrics: true },
|
||||
});
|
||||
|
||||
const saved = JSON.parse(store.get('open-design:config') ?? '{}');
|
||||
expect(saved.installationId).toBeUndefined();
|
||||
expect(saved.privacyDecisionAt).toBeUndefined();
|
||||
expect(saved.telemetry).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ export interface AgentModelPrefs {
|
|||
|
||||
export type AgentCliEnvPrefs = Record<string, Record<string, string>>;
|
||||
|
||||
export interface TelemetryPrefs {
|
||||
metrics?: boolean;
|
||||
content?: boolean;
|
||||
artifactManifest?: boolean;
|
||||
}
|
||||
|
||||
export interface OrbitConfigPrefs {
|
||||
enabled: boolean;
|
||||
/** Local 24-hour clock time in HH:mm format. Defaults to 08:00. */
|
||||
|
|
@ -22,6 +28,16 @@ export interface AppConfigPrefs {
|
|||
designSystemId?: string | null;
|
||||
disabledSkills?: string[];
|
||||
disabledDesignSystems?: string[];
|
||||
installationId?: string | null;
|
||||
telemetry?: TelemetryPrefs;
|
||||
/**
|
||||
* Unix-millis timestamp of when the user resolved the first-run privacy
|
||||
* consent surface (Share or Decline). Set on first decision and on
|
||||
* subsequent toggles in Settings → Privacy. Independent of
|
||||
* installationId so that "Delete my data" can rotate the id without
|
||||
* re-popping the consent banner.
|
||||
*/
|
||||
privacyDecisionAt?: number | null;
|
||||
orbit?: OrbitConfigPrefs;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export type ChatRole = 'user' | 'assistant';
|
|||
export interface ChatRequest {
|
||||
agentId: string;
|
||||
message: string;
|
||||
/** The latest user turn only, used for per-turn telemetry content. */
|
||||
currentPrompt?: string;
|
||||
systemPrompt?: string;
|
||||
projectId?: string | null;
|
||||
conversationId?: string | null;
|
||||
|
|
@ -126,4 +128,10 @@ export interface ChatMessage {
|
|||
attachments?: ChatAttachment[];
|
||||
commentAttachments?: ChatCommentAttachment[];
|
||||
producedFiles?: ProjectFile[];
|
||||
/**
|
||||
* Request-only marker for the final assistant-message persistence pass.
|
||||
* The daemon does not store or return this field; it only uses it to
|
||||
* avoid telemetry reads before content and producedFiles are finalized.
|
||||
*/
|
||||
telemetryFinalized?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type { ProxySseEvent } from './sse/proxy';
|
|||
export const exampleChatRequest: ChatRequest = {
|
||||
agentId: 'claude',
|
||||
message: '## user\nCreate a design',
|
||||
currentPrompt: 'Create a design',
|
||||
systemPrompt: 'Design carefully.',
|
||||
projectId: 'project_1',
|
||||
attachments: ['brief.pdf'],
|
||||
|
|
|
|||
Loading…
Reference in a new issue