Skip to content

Commit 3ee508f

Browse files
authored
[codex] Enable trusted trace object upload authority (#3923)
* Enable trusted trace object upload authority * Fix trace object authority registration edge cases Generated-By: looper 0.9.4 (runner=fixer, agent=codex)
1 parent f6ff9e0 commit 3ee508f

6 files changed

Lines changed: 545 additions & 47 deletions

File tree

apps/daemon/src/langfuse-bridge.ts

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
type MessageSummary,
3333
type ReportContext,
3434
type RuntimeInfo,
35+
type TelemetrySinkConfig,
3536
type ToolCallSummary,
3637
type TurnInfo,
3738
} from './langfuse-trace.js';
@@ -168,6 +169,46 @@ function mergeTraceSafeManifests(
168169
};
169170
}
170171

172+
function inferObjectRegistrationRelayUrl(env: NodeJS.ProcessEnv = process.env): string | null {
173+
const objectRelayUrl = env.OPEN_DESIGN_OBJECT_RELAY_URL?.trim();
174+
if (!objectRelayUrl) return null;
175+
try {
176+
const url = new URL(objectRelayUrl);
177+
url.pathname = url.pathname.replace(/\/api\/objects\/batch\/?$/, '/api/langfuse');
178+
return url.toString().replace(/\/+$/, '');
179+
} catch {
180+
return objectRelayUrl.replace(/\/api\/objects\/batch\/?$/, '/api/langfuse').replace(/\/+$/, '');
181+
}
182+
}
183+
184+
function parsePositiveInt(value: string | undefined, fallback: number): number {
185+
if (value === undefined) return fallback;
186+
const parsed = Number.parseInt(value, 10);
187+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
188+
}
189+
190+
function parseNonNegativeInt(value: string | undefined, fallback: number): number {
191+
if (value === undefined) return fallback;
192+
const parsed = Number.parseInt(value, 10);
193+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
194+
}
195+
196+
function objectRegistrationTelemetryConfig(
197+
env: NodeJS.ProcessEnv = process.env,
198+
): Extract<TelemetrySinkConfig, { kind: 'relay' }> | null {
199+
const relayUrl = inferObjectRegistrationRelayUrl(env);
200+
if (!relayUrl) return null;
201+
return {
202+
kind: 'relay',
203+
relayUrl,
204+
timeoutMs: parsePositiveInt(
205+
env.OPEN_DESIGN_OBJECT_RELAY_TIMEOUT_MS ?? env.OPEN_DESIGN_TELEMETRY_TIMEOUT_MS,
206+
20_000,
207+
),
208+
retries: parseNonNegativeInt(env.OPEN_DESIGN_TELEMETRY_RETRIES, 1),
209+
};
210+
}
211+
171212
function turnInfoFromRun(
172213
run: DaemonRunRecord,
173214
agentReportedModel: string | null,
@@ -874,7 +915,7 @@ export async function reportRunCompletedFromDaemon(
874915
attachmentsRaw,
875916
producedFilesRaw,
876917
});
877-
const uploadedManifests = await buildTraceObjectManifests({
918+
const objectManifestOptions = {
878919
installationId,
879920
projectId: run.projectId ?? '',
880921
runId: run.id,
@@ -885,9 +926,8 @@ export async function reportRunCompletedFromDaemon(
885926
prompt: telemetryPrompt,
886927
prefs,
887928
...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
888-
});
889-
const finalManifests = mergeTraceSafeManifests(manifests, uploadedManifests);
890-
const ctx: ReportContext = {
929+
} satisfies Parameters<typeof buildTraceObjectManifests>[0];
930+
const buildContext = (finalManifests: FinalTraceSafeManifests): ReportContext => ({
891931
installationId,
892932
projectId: run.projectId ?? '',
893933
conversationId: run.conversationId ?? '',
@@ -928,10 +968,26 @@ export async function reportRunCompletedFromDaemon(
928968
...(turn ? { turn } : {}),
929969
runtime,
930970
...(run.promptTelemetry ? { promptTelemetry: run.promptTelemetry } : {}),
931-
};
971+
});
932972

973+
const registrationManifests = await buildTraceObjectManifests({
974+
...objectManifestOptions,
975+
uploadMode: 'manifest-only',
976+
});
977+
if (registrationManifests) {
978+
await reportRunCompleted(
979+
buildContext(mergeTraceSafeManifests(manifests, registrationManifests)),
980+
{
981+
config: objectRegistrationTelemetryConfig(),
982+
...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
983+
},
984+
);
985+
}
986+
987+
const uploadedManifests = await buildTraceObjectManifests(objectManifestOptions);
988+
const finalManifests = mergeTraceSafeManifests(manifests, uploadedManifests);
933989
await reportRunCompleted(
934-
ctx,
990+
buildContext(finalManifests),
935991
opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {},
936992
);
937993
} catch (err) {

apps/daemon/src/trace-object-manifest.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export interface BuildTraceObjectManifestsOptions {
6161
fetchImpl?: typeof fetch;
6262
env?: NodeJS.ProcessEnv;
6363
now?: () => Date;
64+
uploadMode?: 'manifest-only' | 'upload';
6465
}
6566

6667
export interface TraceArtifactObjectSource {
@@ -136,15 +137,7 @@ function storageRef(projectId: string, runId: string, objectClass: ObjectClass,
136137
function inferRelayUrl(env: NodeJS.ProcessEnv): string | null {
137138
const explicit = env.OPEN_DESIGN_OBJECT_RELAY_URL?.trim();
138139
if (explicit) return explicit.replace(/\/+$/, '');
139-
const telemetry = env.OPEN_DESIGN_TELEMETRY_RELAY_URL?.trim();
140-
if (!telemetry) return null;
141-
try {
142-
const url = new URL(telemetry);
143-
url.pathname = url.pathname.replace(/\/api\/langfuse\/?$/, '/api/objects/batch');
144-
return url.toString().replace(/\/+$/, '');
145-
} catch {
146-
return null;
147-
}
140+
return null;
148141
}
149142

150143
function inferAuthorizeUrl(batchUrl: string): string {
@@ -169,7 +162,7 @@ function readRelayConfig(env: NodeJS.ProcessEnv): ObjectRelayConfig | null {
169162
return {
170163
url,
171164
authorizeUrl: inferAuthorizeUrl(url),
172-
uploadsEnabled: env.NODE_ENV === 'test',
165+
uploadsEnabled: true,
173166
timeoutMs: parsePositiveInt(
174167
env.OPEN_DESIGN_OBJECT_RELAY_TIMEOUT_MS ?? env.OPEN_DESIGN_TELEMETRY_TIMEOUT_MS,
175168
10_000,
@@ -250,6 +243,18 @@ function manifestBase(
250243
};
251244
}
252245

246+
function mergeSourceDigest(
247+
entry: TraceObjectManifestEntry,
248+
source: TraceObjectSource,
249+
): TraceObjectManifestEntry {
250+
if (!source.body) return entry;
251+
return {
252+
...entry,
253+
size_bytes: source.body.byteLength,
254+
sha256: sha256(source.body),
255+
};
256+
}
257+
253258
function buildObjectBatchBody(
254259
opts: BuildTraceObjectManifestsOptions,
255260
objects: ObjectRelayRequestObject[],
@@ -640,6 +645,14 @@ export async function buildTraceObjectManifests(
640645
if (sources.length === 0) return undefined;
641646

642647
const manifests = sources.map((source) => manifestBase(source, opts, now));
648+
if (opts.uploadMode === 'manifest-only') {
649+
return groupManifests(manifests.map((entry, index) => ({
650+
...mergeSourceDigest(entry, sources[index]!),
651+
status: 'unavailable' as const,
652+
stored_in_open_design: false,
653+
reason: entry.reason ?? 'relay_authorization_pending',
654+
})));
655+
}
643656

644657
const relayResults = await postObjectBatch(config, opts, manifests, sources);
645658
const resultByRef = new Map(relayResults.map((result) => [result.storage_ref, result]));

apps/daemon/tests/langfuse-bridge.test.ts

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,7 @@ describe('langfuse-bridge.reportRunCompletedFromDaemon', () => {
578578
});
579579

580580
process.env.OPEN_DESIGN_OBJECT_RELAY_URL = 'https://telemetry.open-design.ai/api/objects/batch';
581+
process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL = 'https://telemetry.open-design.ai/api/langfuse';
581582
process.env.LANGFUSE_PUBLIC_KEY = 'pk';
582583
process.env.LANGFUSE_SECRET_KEY = 'sk';
583584
try {
@@ -613,14 +614,17 @@ describe('langfuse-bridge.reportRunCompletedFromDaemon', () => {
613614
});
614615
} finally {
615616
delete process.env.OPEN_DESIGN_OBJECT_RELAY_URL;
617+
delete process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL;
616618
delete process.env.LANGFUSE_PUBLIC_KEY;
617619
delete process.env.LANGFUSE_SECRET_KEY;
618620
}
619621

620-
expect(fetchSpy).toHaveBeenCalledTimes(3);
621-
expect(fetchSpy.mock.calls[0]![0]).toContain('/api/objects/authorize');
622-
expect(fetchSpy.mock.calls[1]![0]).toContain('/api/objects/batch');
623-
const langfuseInit = fetchSpy.mock.calls[2]![1] as RequestInit;
622+
expect(fetchSpy).toHaveBeenCalledTimes(4);
623+
expect(fetchSpy.mock.calls[0]![0]).toContain('/api/langfuse');
624+
expect(fetchSpy.mock.calls[1]![0]).toContain('/api/objects/authorize');
625+
expect(fetchSpy.mock.calls[2]![0]).toContain('/api/objects/batch');
626+
expect(fetchSpy.mock.calls[3]![0]).toContain('/api/langfuse');
627+
const langfuseInit = fetchSpy.mock.calls[3]![1] as RequestInit;
624628
const langfuseBody = langfuseInit.body as string;
625629
expect(langfuseBody).not.toContain('attachment body should stay out of langfuse');
626630
expect(langfuseBody).not.toContain('<!doctype html><h1>artifact body</h1>');
@@ -658,6 +662,88 @@ describe('langfuse-bridge.reportRunCompletedFromDaemon', () => {
658662
);
659663
});
660664

665+
it('registers object upload authority through the object relay when traces use direct Langfuse', async () => {
666+
await writeAppCfg({
667+
installationId: 'install-uuid-1',
668+
telemetry: { metrics: true, content: true, artifactManifest: true },
669+
});
670+
const projectDir = path.join(dataDir, 'projects', 'proj-1');
671+
await mkdir(projectDir, { recursive: true });
672+
await writeFile(path.join(projectDir, 'index.html'), '<!doctype html><h1>artifact body</h1>');
673+
const fetchSpy = vi.fn(async (url: string, init: RequestInit) => {
674+
if (url.includes('/api/objects/authorize')) {
675+
const parsed = JSON.parse(init.body as string) as {
676+
objects: Array<{ storage_ref: string; object_class: string }>;
677+
};
678+
expect(parsed.objects).toHaveLength(1);
679+
expect(parsed.objects[0]).toMatchObject({ object_class: 'artifact' });
680+
return new Response(JSON.stringify({ upload_token: 'upload-token' }), { status: 200 });
681+
}
682+
if (url.includes('/api/objects/batch')) {
683+
const parsed = JSON.parse(init.body as string) as {
684+
objects: Array<{ storage_ref: string; content_base64: string }>;
685+
};
686+
return new Response(
687+
JSON.stringify({
688+
objects: parsed.objects.map((object) => ({
689+
storage_ref: object.storage_ref,
690+
status: 'available',
691+
size_bytes: Buffer.from(object.content_base64, 'base64').byteLength,
692+
sha256: 'sha256:uploaded-artifact',
693+
})),
694+
}),
695+
{ status: 200 },
696+
);
697+
}
698+
return new Response('{}', { status: 207 });
699+
});
700+
701+
process.env.OPEN_DESIGN_OBJECT_RELAY_URL = 'https://telemetry.open-design.ai/api/objects/batch';
702+
process.env.LANGFUSE_PUBLIC_KEY = 'pk';
703+
process.env.LANGFUSE_SECRET_KEY = 'sk';
704+
try {
705+
await reportRunCompletedFromDaemon({
706+
db: makeDbWithListMessages({
707+
'conv-1': [
708+
{ id: 'user-1', role: 'user', content: 'Build it.' },
709+
{
710+
id: 'msg-1',
711+
role: 'assistant',
712+
content: 'done',
713+
producedFiles: [{ name: 'index.html', kind: 'html', size: 35 }],
714+
},
715+
],
716+
}),
717+
dataDir,
718+
run: makeRun() as any,
719+
fetchImpl: fetchSpy as any,
720+
});
721+
} finally {
722+
delete process.env.OPEN_DESIGN_OBJECT_RELAY_URL;
723+
delete process.env.LANGFUSE_PUBLIC_KEY;
724+
delete process.env.LANGFUSE_SECRET_KEY;
725+
}
726+
727+
expect(fetchSpy).toHaveBeenCalledTimes(4);
728+
expect(fetchSpy.mock.calls[0]![0]).toBe('https://telemetry.open-design.ai/api/langfuse');
729+
expect(fetchSpy.mock.calls[1]![0]).toBe('https://telemetry.open-design.ai/api/objects/authorize');
730+
expect(fetchSpy.mock.calls[2]![0]).toBe('https://telemetry.open-design.ai/api/objects/batch');
731+
expect(fetchSpy.mock.calls[3]![0]).toBe('https://us.cloud.langfuse.com/api/public/ingestion');
732+
const registrationBatch = JSON.parse(fetchSpy.mock.calls[0]![1]!.body as string).batch as any[];
733+
const finalBatch = JSON.parse(fetchSpy.mock.calls[3]![1]!.body as string).batch as any[];
734+
expect(registrationBatch[0].body.metadata.artifact_manifest[0]).toMatchObject({
735+
object_class: 'artifact',
736+
storage_ref: expect.stringContaining(
737+
'od://objects/workspaces/unknown/projects/proj-1/runs/run-id-1/artifact/',
738+
),
739+
});
740+
expect(finalBatch[0].body.metadata.artifact_manifest[0]).toMatchObject({
741+
object_class: 'artifact',
742+
status: 'ok',
743+
stored_in_open_design: true,
744+
});
745+
});
746+
661747
it('derives manifest completeness from merged uploaded and fallback manifests', async () => {
662748
await writeAppCfg({
663749
installationId: 'install-uuid-1',
@@ -695,6 +781,7 @@ describe('langfuse-bridge.reportRunCompletedFromDaemon', () => {
695781
});
696782

697783
process.env.OPEN_DESIGN_OBJECT_RELAY_URL = 'https://telemetry.open-design.ai/api/objects/batch';
784+
process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL = 'https://telemetry.open-design.ai/api/langfuse';
698785
process.env.LANGFUSE_PUBLIC_KEY = 'pk';
699786
process.env.LANGFUSE_SECRET_KEY = 'sk';
700787
try {
@@ -721,12 +808,16 @@ describe('langfuse-bridge.reportRunCompletedFromDaemon', () => {
721808
});
722809
} finally {
723810
delete process.env.OPEN_DESIGN_OBJECT_RELAY_URL;
811+
delete process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL;
724812
delete process.env.LANGFUSE_PUBLIC_KEY;
725813
delete process.env.LANGFUSE_SECRET_KEY;
726814
}
727815

728-
expect(fetchSpy).toHaveBeenCalledTimes(3);
729-
const langfuseInit = fetchSpy.mock.calls[2]![1] as RequestInit;
816+
expect(fetchSpy).toHaveBeenCalledTimes(4);
817+
expect(fetchSpy.mock.calls[0]![0]).toContain('/api/langfuse');
818+
expect(fetchSpy.mock.calls[1]![0]).toContain('/api/objects/authorize');
819+
expect(fetchSpy.mock.calls[2]![0]).toContain('/api/objects/batch');
820+
const langfuseInit = fetchSpy.mock.calls[3]![1] as RequestInit;
730821
const batch = JSON.parse(langfuseInit.body as string).batch as any[];
731822
const trace = batch[0].body;
732823
expect(trace.metadata.manifest_completeness).toBe('partial');

apps/telemetry-worker/README.md

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@ them through the `TRACE_OBJECT_BUCKET` R2 binding, and returns trace-safe
1818
`storage_ref` / `sha256` / size metadata for Langfuse manifests.
1919

2020
Object ingest accepts a short-lived upload token signed by Worker-held
21-
authority. Public metadata-only authorization is disabled until the Worker can
22-
verify a trusted telemetry authority for the requested objects. The long-lived
23-
signing secret stays in the Worker and is never packaged into the daemon/client.
24-
Until that trusted authority exists, production daemon telemetry emits trace-safe
25-
object manifests only and does not attempt object authorization or upload.
21+
authority. The Worker issues that token only after the same Worker has already
22+
seen the run's trace-safe object scope in a normal `/api/langfuse` telemetry
23+
batch and stored it in `TRACE_OBJECT_SCOPE_KV`. Caller-supplied metadata plus
24+
the public marker header is not enough to authorize a production upload. The
25+
long-lived signing secret stays in the Worker and is never packaged into the
26+
daemon/client.
2627

2728
Local development can bypass the relay by setting direct `LANGFUSE_PUBLIC_KEY`
2829
and `LANGFUSE_SECRET_KEY` environment variables for the daemon. Packaged
@@ -42,13 +43,12 @@ Rate Limiting bindings for two independent keys:
4243
Object ingest uses the same rate limit bindings with a separate marker value,
4344
`X-Open-Design-Telemetry: object-ingestion-v1`. `POST /api/objects/batch` must
4445
include a signed upload token, and the Worker re-checks the namespace, size, and
45-
sha256 before writing to R2. `POST /api/objects/authorize` does not issue
46-
production upload tokens from caller-supplied metadata; it is limited to the
47-
explicit `TRACE_OBJECT_AUTHORIZE_TEST_ONLY=1` harness until a server-verifiable
48-
telemetry authority exists. The Worker also applies IP rate limiting before
49-
reading object bodies. It enforces a 10 MiB single-object limit and a 20 MiB
50-
request-body limit by default. Oversized objects are reported as unavailable
51-
instead of being written.
46+
sha256 before writing to R2. `POST /api/objects/authorize` reads only bounded
47+
metadata, checks that every requested object exactly matches a previously
48+
registered telemetry scope, then returns a five-minute token. The Worker also
49+
applies IP rate limiting before reading object bodies. It enforces a 10 MiB
50+
single-object limit and a 20 MiB request-body limit by default. Oversized
51+
objects are reported as unavailable instead of being written.
5252

5353
## Secrets
5454

@@ -69,6 +69,10 @@ packaged client or daemon. Required worker configuration:
6969
binding = "TRACE_OBJECT_BUCKET"
7070
bucket_name = "open-design-observability"
7171

72+
[[kv_namespaces]]
73+
binding = "TRACE_OBJECT_SCOPE_KV"
74+
id = "<cloudflare-kv-namespace-id>"
75+
7276
[vars]
7377
TRACE_OBJECT_PREFIX = "observability"
7478
TRACE_OBJECT_MAX_BYTES = "10485760"

0 commit comments

Comments
 (0)