1- import { zip } from "fflate" ;
1+ import { Zip , ZipDeflate } from "fflate" ;
2+ import { createReadStream , createWriteStream , type WriteStream } from "node:fs" ;
23import * as fs from "node:fs/promises" ;
4+ import * as os from "node:os" ;
35import * as path from "node:path" ;
4- import { promisify } from "node:util" ;
56
6- import { wrapError } from "../../../../error/errorUtils" ;
7+ import { isAbortError , toError , wrapError } from "../../../../error/errorUtils" ;
78import { writeAtomically } from "../../../../util/fs" ;
89import { describeMetricEvent } from "../../metrics" ;
910
@@ -24,49 +25,90 @@ import {
2425
2526import type { TelemetryContext , TelemetryEvent } from "../../../event" ;
2627
28+ /** Event totals by signal — a metric event with all records suppressed still counts as one. */
2729export interface OtlpExportCounts {
2830 readonly logs : number ;
2931 readonly traces : number ;
3032 readonly metrics : number ;
3133}
3234
35+ export interface OtlpWriteOptions {
36+ readonly signal ?: AbortSignal ;
37+ readonly onTempCleanupError ?: ( err : unknown , tempPath : string ) => void ;
38+ /** Fires on either success or failure path so cleanup errors never mask the export outcome. */
39+ readonly onStagingCleanupError ?: ( err : unknown , dir : string ) => void ;
40+ }
41+
3342interface Channel {
3443 file : EnvelopeFile ;
3544 count : number ;
3645}
3746
38- const zipAsync = promisify ( zip ) ;
47+ const READ_HWM_BYTES = 256 * 1024 ;
3948
4049/**
4150 * Writes `events` as an OTLP/JSON zip (`logs.json`, `traces.json`,
42- * `metrics.json`) to `outputPath`.
51+ * `metrics.json`) to `outputPath`. Staging happens in the OS temp dir so
52+ * cloud-sync agents on the user's chosen save location never see the
53+ * intermediate uncompressed envelopes.
4354 */
4455export async function writeOtlpZipExport (
4556 outputPath : string ,
4657 events : AsyncIterable < TelemetryEvent > ,
4758 context : TelemetryContext ,
48- onCleanupError : ( err : unknown , tempPath : string ) => void ,
59+ options : OtlpWriteOptions = { } ,
4960) : Promise < OtlpExportCounts > {
61+ throwIfAborted ( options . signal ) ;
5062 return writeAtomically (
5163 outputPath ,
5264 async ( zipPath ) => {
53- const stagingDir = await fs . mkdtemp ( `${ outputPath } .staging-` ) ;
65+ const stagingDir = await fs . mkdtemp (
66+ path . join ( os . tmpdir ( ) , "coder-telemetry-otlp-" ) ,
67+ ) ;
68+ let counts : OtlpExportCounts ;
5469 try {
55- const counts = await writeStagedFiles ( stagingDir , events , context ) ;
56- await packZip ( zipPath , stagingDir ) ;
57- return counts ;
58- } finally {
59- await fs . rm ( stagingDir , { recursive : true , force : true } ) ;
70+ counts = await writeStagedFiles (
71+ stagingDir ,
72+ events ,
73+ context ,
74+ options . signal ,
75+ ) ;
76+ await packZip ( zipPath , stagingDir , options . signal ) ;
77+ } catch ( err ) {
78+ await safeRemove ( stagingDir , options . onStagingCleanupError ) ;
79+ throw err ;
6080 }
81+ await safeRemove ( stagingDir , options . onStagingCleanupError ) ;
82+ return counts ;
6183 } ,
62- onCleanupError ,
84+ options . onTempCleanupError ?? swallowCleanupError ,
6385 ) ;
6486}
6587
88+ function swallowCleanupError ( ) : void {
89+ /* Default: temp-cleanup errors from writeAtomically are silently dropped. */
90+ }
91+
92+ async function safeRemove (
93+ dir : string ,
94+ onError ?: ( err : unknown , dir : string ) => void ,
95+ ) : Promise < void > {
96+ try {
97+ await fs . rm ( dir , { recursive : true , force : true } ) ;
98+ } catch ( err ) {
99+ try {
100+ onError ?.( err , dir ) ;
101+ } catch {
102+ // Swallow callback throws so they don't displace the export error.
103+ }
104+ }
105+ }
106+
66107async function writeStagedFiles (
67108 dir : string ,
68109 events : AsyncIterable < TelemetryEvent > ,
69110 context : TelemetryContext ,
111+ signal : AbortSignal | undefined ,
70112) : Promise < OtlpExportCounts > {
71113 const resource = JSON . stringify ( otlpResource ( context ) ) ;
72114 const scope = JSON . stringify ( otlpScope ( context . extensionVersion ) ) ;
@@ -76,6 +118,7 @@ async function writeStagedFiles(
76118 let succeeded = false ;
77119 try {
78120 for await ( const event of events ) {
121+ throwIfAborted ( signal ) ;
79122 await routeEvent ( event , channels , state ) ;
80123 }
81124 succeeded = true ;
@@ -146,10 +189,13 @@ async function routeEvent(
146189 channels . metrics ,
147190 metricRecords ( event , metric , state ) ,
148191 ) ;
192+ channels . metrics . count += 1 ;
149193 } else if ( hasTraceId ( event ) ) {
150194 await appendRecords ( channels . traces , [ spanRecord ( event ) ] ) ;
195+ channels . traces . count += 1 ;
151196 } else {
152197 await appendRecords ( channels . logs , [ logRecord ( event ) ] ) ;
198+ channels . logs . count += 1 ;
153199 }
154200 } catch ( err ) {
155201 throw wrapError (
@@ -164,29 +210,118 @@ async function appendRecords(
164210 channel : Channel ,
165211 records : Iterable < unknown > ,
166212) : Promise < void > {
167- let wrote = false ;
168213 for ( const record of records ) {
169214 await channel . file . append ( record ) ;
170- wrote = true ;
171- }
172- if ( wrote ) {
173- channel . count += 1 ;
174215 }
175216}
176217
177- async function packZip ( outputPath : string , sourceDir : string ) : Promise < void > {
218+ /** Streams the staged envelopes into a deflate-compressed zip; AbortError is rethrown unwrapped. */
219+ async function packZip (
220+ outputPath : string ,
221+ sourceDir : string ,
222+ signal : AbortSignal | undefined ,
223+ ) : Promise < void > {
224+ const outStream = createWriteStream ( outputPath ) ;
178225 try {
179- const entries = await Promise . all (
180- Object . values ( ENVELOPES ) . map (
181- async ( envelope ) =>
182- [
183- envelope . file ,
184- await fs . readFile ( path . join ( sourceDir , envelope . file ) ) ,
185- ] as const ,
186- ) ,
187- ) ;
188- await fs . writeFile ( outputPath , await zipAsync ( Object . fromEntries ( entries ) ) ) ;
226+ await streamEnvelopesIntoZip ( outStream , sourceDir , signal ) ;
189227 } catch ( err ) {
228+ outStream . destroy ( ) ;
229+ if ( isAbortError ( err ) ) {
230+ throw err ;
231+ }
190232 throw wrapError ( "pack OTLP zip" , path . basename ( outputPath ) , err ) ;
191233 }
192234}
235+
236+ /** Bridges fflate's Zip callback onto `outStream`; the pump awaits 'drain' on backpressure. */
237+ function streamEnvelopesIntoZip (
238+ outStream : WriteStream ,
239+ sourceDir : string ,
240+ signal : AbortSignal | undefined ,
241+ ) : Promise < void > {
242+ return new Promise < void > ( ( resolve , reject ) => {
243+ const fail = ( err : unknown ) : void => reject ( toError ( err ) ) ;
244+
245+ const waitForDrain = ( ) : Promise < void > =>
246+ outStream . writableNeedDrain
247+ ? new Promise < void > ( ( r ) => outStream . once ( "drain" , r ) )
248+ : Promise . resolve ( ) ;
249+
250+ outStream . on ( "error" , fail ) ;
251+
252+ const zip = new Zip ( ( err , chunk , final ) => {
253+ if ( err ) {
254+ fail ( err ) ;
255+ return ;
256+ }
257+ if ( final ) {
258+ // end() waits for in-flight writes before 'finish'; no pendingWrites counter needed.
259+ outStream . end ( chunk , ( ) => resolve ( ) ) ;
260+ } else {
261+ outStream . write ( chunk , ( writeErr ) => {
262+ if ( writeErr ) {
263+ fail ( writeErr ) ;
264+ }
265+ } ) ;
266+ }
267+ } ) ;
268+
269+ void pumpEnvelopes ( zip , sourceDir , signal , waitForDrain ) . catch ( ( err ) => {
270+ zip . terminate ( ) ;
271+ fail ( err ) ;
272+ } ) ;
273+ } ) ;
274+ }
275+
276+ async function pumpEnvelopes (
277+ zip : Zip ,
278+ sourceDir : string ,
279+ signal : AbortSignal | undefined ,
280+ waitForDrain : ( ) => Promise < void > ,
281+ ) : Promise < void > {
282+ for ( const envelope of Object . values ( ENVELOPES ) ) {
283+ throwIfAborted ( signal ) ;
284+ await streamFileIntoZip (
285+ zip ,
286+ envelope . file ,
287+ path . join ( sourceDir , envelope . file ) ,
288+ signal ,
289+ waitForDrain ,
290+ ) ;
291+ }
292+ zip . end ( ) ;
293+ }
294+
295+ async function streamFileIntoZip (
296+ zip : Zip ,
297+ name : string ,
298+ filePath : string ,
299+ signal : AbortSignal | undefined ,
300+ waitForDrain : ( ) => Promise < void > ,
301+ ) : Promise < void > {
302+ const entry = new ZipDeflate ( name ) ;
303+ zip . add ( entry ) ;
304+ const readStream = createReadStream ( filePath , {
305+ highWaterMark : READ_HWM_BYTES ,
306+ } ) ;
307+ try {
308+ for await ( const chunk of readStream ) {
309+ throwIfAborted ( signal ) ;
310+ entry . push ( chunk as Uint8Array , false ) ;
311+ await waitForDrain ( ) ;
312+ }
313+ entry . push ( new Uint8Array ( 0 ) , true ) ;
314+ } finally {
315+ readStream . destroy ( ) ;
316+ }
317+ }
318+
319+ /** Like AbortSignal.throwIfAborted() but coerces non-Error reasons to a named AbortError. */
320+ function throwIfAborted ( signal : AbortSignal | undefined ) : void {
321+ if ( signal ?. aborted ) {
322+ const reason : unknown = signal . reason ;
323+ throw reason instanceof Error
324+ ? reason
325+ : Object . assign ( new Error ( "Aborted" ) , { name : "AbortError" } ) ;
326+ }
327+ }
0 commit comments