11/* @effect -diagnostics nodeBuiltinImport:off -- this suite spawns the CLI and serves a fake provider and collector. */
22/* @effect -diagnostics globalTimers:off -- the subprocess deadline runs outside any Effect. */
33import { spawn } from "node:child_process" ;
4- import { mkdirSync , mkdtempSync , rmSync , writeFileSync } from "node:fs" ;
4+ import { mkdirSync , mkdtempSync , readFileSync , rmSync , writeFileSync } from "node:fs" ;
55import { createServer , type IncomingMessage } from "node:http" ;
66import type { AddressInfo } from "node:net" ;
77import { tmpdir } from "node:os" ;
@@ -301,68 +301,81 @@ const exportedSpans = (exports: ReadonlyArray<Export>) => {
301301 ] . sort ( ) ;
302302} ;
303303
304- /** Every exported resource, traces and metrics alike, must describe the same process. */
305304const resources = ( exports : ReadonlyArray < Export > ) =>
306305 exports . flatMap ( ( entry ) => [ ...( entry . body . resourceSpans ?? [ ] ) , ...( entry . body . resourceMetrics ?? [ ] ) ] ) ;
307306
307+ /** Run the CLI once against `collector` as its provider and OTLP endpoint, with no other `OTEL_*` set. */
308+ const exportRun = async ( collector : Awaited < ReturnType < typeof serve > > , traceLevel ?: string ) => {
309+ const home = mkdtempSync ( join ( tmpdir ( ) , "codework-otel-" ) ) ;
310+ temps . push ( home ) ;
311+ const project = join ( home , "project" ) ;
312+ const settings = join ( home , "settings" ) ;
313+ mkdirSync ( project ) ;
314+ mkdirSync ( settings ) ;
315+ writeFileSync ( join ( project , "note.txt" ) , "hello from the note\n" ) ;
316+ writeFileSync (
317+ join ( settings , "settings.jsonc" ) ,
318+ JSON . stringify ( {
319+ retry : { baseDelayMs : 10 } ,
320+ model : { provider : "otel" , id : "otel-model" } ,
321+ models : [
322+ {
323+ id : "otel-model" ,
324+ name : "otel-model" ,
325+ provider : { id : "otel" , name : "Otel" , source : "config" , env : [ ] } ,
326+ baseUrl : `${ collector . url } /v1` ,
327+ reasoning : false ,
328+ input : [ "text" ] ,
329+ cost : { input : 3 , output : 15 , cacheRead : 0 , cacheWrite : 0 } ,
330+ contextWindow : 32768 ,
331+ maxTokens : 8192 ,
332+ protocol : "openai-compatible" ,
333+ } ,
334+ ] ,
335+ } ) ,
336+ ) ;
337+
338+ const env : NodeJS . ProcessEnv = {
339+ ...process . env ,
340+ HOME : home ,
341+ CODEWORK_MODELS_FILE : models ,
342+ OTEL_EXPORTER_OTLP_ENDPOINT : collector . url ,
343+ OTEL_EXPORTER_OTLP_HEADERS : "x-otel-test=present" ,
344+ // The collector reads JSON; protobuf is the default otherwise.
345+ OTEL_EXPORTER_OTLP_PROTOCOL : "http/json" ,
346+ } ;
347+ const configured = new Set ( [
348+ "OTEL_EXPORTER_OTLP_ENDPOINT" ,
349+ "OTEL_EXPORTER_OTLP_HEADERS" ,
350+ "OTEL_EXPORTER_OTLP_PROTOCOL" ,
351+ ] ) ;
352+ for ( const key of Object . keys ( env ) ) {
353+ if ( key . startsWith ( "OTEL_" ) && ! configured . has ( key ) ) delete env [ key ] ;
354+ }
355+ delete env . CODEWORK_TRACE_LEVEL ;
356+ if ( traceLevel !== undefined ) env . CODEWORK_TRACE_LEVEL = traceLevel ;
357+
358+ const result = await runCli (
359+ [ "--home" , home , "--database" , ":memory:" , "--user-config-dir" , settings , "run" , "What does note.txt say?" ] ,
360+ { cwd : project , env } ,
361+ ) ;
362+ expect ( result . status , result . stderr ) . toBe ( 0 ) ;
363+ expect ( result . stdout ) . toContain ( "The note says hello." ) ;
364+
365+ // No prompt, command, file content, or model output may leave in any span, attribute, or event.
366+ const traces = JSON . stringify ( collector . exports . filter ( ( entry ) => entry . path === "/v1/traces" ) ) ;
367+ for ( const content of [ "What does note.txt say?" , "cat note.txt" , "hello from the note" , "The note says hello." ] ) {
368+ expect ( traces ) . not . toContain ( content ) ;
369+ }
370+ } ;
371+
372+ const artifact = fileURLToPath ( new URL ( "./__artifacts__/telemetry.json" , import . meta. url ) ) ;
373+
308374describe ( "OTLP export" , ( ) => {
309375 it ( "exports GenAI spans and metrics for a run through the standard OTEL_* environment" , async ( ) => {
310376 const collector = await serve ( ) ;
311377 try {
312- const home = mkdtempSync ( join ( tmpdir ( ) , "codework-otel-" ) ) ;
313- temps . push ( home ) ;
314- const project = join ( home , "project" ) ;
315- const settings = join ( home , "settings" ) ;
316- mkdirSync ( project ) ;
317- mkdirSync ( settings ) ;
318- writeFileSync ( join ( project , "note.txt" ) , "hello from the note\n" ) ;
319- writeFileSync (
320- join ( settings , "settings.jsonc" ) ,
321- JSON . stringify ( {
322- retry : { baseDelayMs : 10 } ,
323- model : { provider : "otel" , id : "otel-model" } ,
324- models : [
325- {
326- id : "otel-model" ,
327- name : "otel-model" ,
328- provider : { id : "otel" , name : "Otel" , source : "config" , env : [ ] } ,
329- baseUrl : `${ collector . url } /v1` ,
330- reasoning : false ,
331- input : [ "text" ] ,
332- cost : { input : 3 , output : 15 , cacheRead : 0 , cacheWrite : 0 } ,
333- contextWindow : 32768 ,
334- maxTokens : 8192 ,
335- protocol : "openai-compatible" ,
336- } ,
337- ] ,
338- } ) ,
339- ) ;
340-
341- const env : NodeJS . ProcessEnv = {
342- ...process . env ,
343- HOME : home ,
344- CODEWORK_MODELS_FILE : models ,
345- OTEL_EXPORTER_OTLP_ENDPOINT : collector . url ,
346- OTEL_EXPORTER_OTLP_HEADERS : "x-otel-test=present" ,
347- // The collector reads JSON; protobuf is the default otherwise.
348- OTEL_EXPORTER_OTLP_PROTOCOL : "http/json" ,
349- } ;
350- const configured = new Set ( [
351- "OTEL_EXPORTER_OTLP_ENDPOINT" ,
352- "OTEL_EXPORTER_OTLP_HEADERS" ,
353- "OTEL_EXPORTER_OTLP_PROTOCOL" ,
354- ] ) ;
355- for ( const key of Object . keys ( env ) ) {
356- if ( key . startsWith ( "OTEL_" ) && ! configured . has ( key ) ) delete env [ key ] ;
357- }
358- delete env . CODEWORK_TRACE_LEVEL ;
359-
360- const result = await runCli (
361- [ "--home" , home , "--database" , ":memory:" , "--user-config-dir" , settings , "run" , "What does note.txt say?" ] ,
362- { cwd : project , env } ,
363- ) ;
364- expect ( result . status , result . stderr ) . toBe ( 0 ) ;
365- expect ( result . stdout ) . toContain ( "The note says hello." ) ;
378+ await exportRun ( collector ) ;
366379
367380 expect ( collector . exports . length ) . toBeGreaterThan ( 0 ) ;
368381 expect ( new Set ( collector . exports . map ( ( entry ) => entry . path ) ) ) . toEqual ( new Set ( [ "/v1/traces" , "/v1/metrics" ] ) ) ;
@@ -371,17 +384,6 @@ describe("OTLP export", () => {
371384 const exported = resources ( collector . exports ) . map ( ( entry ) => JSON . stringify ( entry . resource ) ) ;
372385 expect ( new Set ( exported ) . size , "every export carries one resource" ) . toBe ( 1 ) ;
373386
374- // No prompt, file content, or model output may leave in any span, attribute, or event.
375- const traces = JSON . stringify ( collector . exports . filter ( ( entry ) => entry . path === "/v1/traces" ) ) ;
376- for ( const content of [
377- "What does note.txt say?" ,
378- "cat note.txt" ,
379- "hello from the note" ,
380- "The note says hello." ,
381- ] ) {
382- expect ( traces ) . not . toContain ( content ) ;
383- }
384-
385387 await expect (
386388 `${ JSON . stringify (
387389 {
@@ -398,4 +400,18 @@ describe("OTLP export", () => {
398400 await collector . close ( ) ;
399401 }
400402 } , 60_000 ) ;
403+
404+ it ( "exports every span, Effect's own sql.* included, with CODEWORK_TRACE_LEVEL=debug" , async ( ) => {
405+ const collector = await serve ( ) ;
406+ try {
407+ await exportRun ( collector , "debug" ) ;
408+ const spans = exportedSpans ( collector . exports ) ;
409+ expect ( spans . some ( ( pair ) => pair . endsWith ( "> sql.execute" ) ) ) . toBe ( true ) ;
410+ // Debug adds spans; it never loses the ones exported by default.
411+ const defaults : ReadonlyArray < string > = JSON . parse ( readFileSync ( artifact , "utf8" ) ) . exported ;
412+ expect ( spans ) . toEqual ( expect . arrayContaining ( [ ...defaults ] ) ) ;
413+ } finally {
414+ await collector . close ( ) ;
415+ }
416+ } , 60_000 ) ;
401417} ) ;
0 commit comments