@@ -216,6 +216,7 @@ import {
216216 transferBudgetViolations ,
217217} from "../integration/TransferBudgetReport.integration.ts" ;
218218import { symlinksSupported } from "@t3tools/shared/testing/symlinks" ;
219+ import { otlpSerializationLayer } from "@t3tools/shared/observability" ;
219220
220221const defaultProjectId = ProjectId . make ( "project-default" ) ;
221222const defaultThreadId = ThreadId . make ( "thread-default" ) ;
@@ -1061,6 +1062,7 @@ const buildAppUnderTest = (options?: {
10611062 ...options ?. layers ?. browserTraceCollector ,
10621063 } ) ,
10631064 ) ,
1065+ Layer . provide ( otlpSerializationLayer ( config . otlpProtocol ) ) ,
10641066 Layer . provide (
10651067 Layer . mock ( ServerLifecycleEvents . ServerLifecycleEvents ) ( {
10661068 publish : ( event ) => Effect . succeed ( { ...( event as any ) , sequence : 1 } ) ,
@@ -5217,6 +5219,110 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
52175219 } ) . pipe ( Effect . provide ( NodeHttpServer . layerTest ) ) ,
52185220 ) ;
52195221
5222+ it . effect ( "forwards browser OTLP traces as protobuf when the protocol is http/protobuf" , ( ) =>
5223+ Effect . gen ( function * ( ) {
5224+ const upstreamRequests : Array < {
5225+ readonly body : string ;
5226+ readonly contentType : string | null ;
5227+ } > = [ ] ;
5228+ const localTraceRecords : Array < unknown > = [ ] ;
5229+ // Produced by effect's own tracer, so enum fields are numeric and the
5230+ // protobuf encoder accepts them. The hand-written payload in the JSON
5231+ // test uses enum names, which only the JSON path tolerates.
5232+ const payload = yield * makeBrowserOtlpPayload ( "client.protobuf.test" ) ;
5233+
5234+ const collector = yield * Effect . acquireRelease (
5235+ Effect . promise ( async ( ) => {
5236+ const NodeHttp = await import ( "node:http" ) ;
5237+
5238+ return await new Promise < {
5239+ readonly close : ( ) => Promise < void > ;
5240+ readonly url : string ;
5241+ } > ( ( resolve , reject ) => {
5242+ const server = NodeHttp . createServer ( ( request , response ) => {
5243+ const chunks : Buffer [ ] = [ ] ;
5244+ request . on ( "data" , ( chunk ) => {
5245+ chunks . push ( Buffer . isBuffer ( chunk ) ? chunk : Buffer . from ( chunk ) ) ;
5246+ } ) ;
5247+ request . on ( "end" , ( ) => {
5248+ upstreamRequests . push ( {
5249+ body : Buffer . concat ( chunks ) . toString ( "utf8" ) ,
5250+ contentType : request . headers [ "content-type" ] ?? null ,
5251+ } ) ;
5252+ response . statusCode = 204 ;
5253+ response . end ( ) ;
5254+ } ) ;
5255+ } ) ;
5256+
5257+ server . on ( "error" , reject ) ;
5258+ server . listen ( 0 , "127.0.0.1" , ( ) => {
5259+ const address = server . address ( ) ;
5260+ if ( ! address || typeof address === "string" ) {
5261+ reject ( new Error ( "Expected TCP collector address" ) ) ;
5262+ return ;
5263+ }
5264+
5265+ resolve ( {
5266+ url : `http://127.0.0.1:${ address . port } /v1/traces` ,
5267+ close : ( ) =>
5268+ new Promise < void > ( ( resolveClose , rejectClose ) => {
5269+ server . close ( ( error ) => {
5270+ if ( error ) {
5271+ rejectClose ( error ) ;
5272+ return ;
5273+ }
5274+ resolveClose ( ) ;
5275+ } ) ;
5276+ } ) ,
5277+ } ) ;
5278+ } ) ;
5279+ } ) ;
5280+ } ) ,
5281+ ( { close } ) => Effect . promise ( close ) ,
5282+ ) ;
5283+
5284+ yield * buildAppUnderTest ( {
5285+ config : {
5286+ otlpTracesUrl : collector . url ,
5287+ otlpProtocol : "http/protobuf" ,
5288+ } ,
5289+ layers : {
5290+ browserTraceCollector : {
5291+ record : ( records ) =>
5292+ Effect . sync ( ( ) => {
5293+ localTraceRecords . push ( ...records ) ;
5294+ } ) ,
5295+ } ,
5296+ } ,
5297+ } ) ;
5298+
5299+ const response = yield * HttpClient . post ( "/api/observability/v1/traces" , {
5300+ headers : {
5301+ cookie : yield * getAuthenticatedSessionCookieHeader ( ) ,
5302+ "content-type" : "application/json" ,
5303+ } ,
5304+ // @effect -diagnostics-next-line preferSchemaOverJson:off
5305+ body : HttpBody . text ( JSON . stringify ( payload ) , "application/json" ) ,
5306+ } ) ;
5307+
5308+ assert . equal ( response . status , 204 ) ;
5309+ // The local collector still decodes the browser's JSON before forwarding.
5310+ assert . equal ( localTraceRecords . length , 1 ) ;
5311+ assert . equal ( upstreamRequests . length , 1 ) ;
5312+ const forwarded = upstreamRequests [ 0 ] ;
5313+ assert . notEqual ( forwarded , undefined ) ;
5314+ if ( ! forwarded ) {
5315+ return ;
5316+ }
5317+ assert . equal ( forwarded . contentType , "application/x-protobuf" ) ;
5318+ // Protobuf strings are raw UTF-8, so the span and service names survive
5319+ // the stub's utf8 decode even though the surrounding bytes don't.
5320+ assert . notEqual ( forwarded . body [ 0 ] , "{" ) ;
5321+ assert . include ( forwarded . body , "client.protobuf.test" ) ;
5322+ assert . include ( forwarded . body , "t3-web" ) ;
5323+ } ) . pipe ( Effect . provide ( NodeHttpServer . layerTest ) ) ,
5324+ ) ;
5325+
52205326 it . effect ( "responds to browser OTLP trace preflight requests with CORS headers" , ( ) =>
52215327 Effect . gen ( function * ( ) {
52225328 yield * buildAppUnderTest ( ) ;
0 commit comments