@@ -2,6 +2,32 @@ import fs from 'node:fs'
22import path from 'node:path'
33import { createServer } from './server.js'
44
5+ /**
6+ * @typedef {{
7+ * serviceName: string,
8+ * timestamp?: string,
9+ * observedTimestamp?: string,
10+ * severityNumber?: number,
11+ * severityText?: string,
12+ * body: unknown,
13+ * traceId?: string,
14+ * spanId?: string,
15+ * flags?: number,
16+ * droppedAttributesCount?: number,
17+ * resource: Record<string, unknown>,
18+ * scope: {
19+ * name?: string,
20+ * version?: string,
21+ * attributes: Record<string, unknown>,
22+ * },
23+ * attributes: Record<string, unknown>,
24+ * }} NormalizedLogRow
25+ */
26+
27+ const OTLP_NS_PER_MS = 1000000n
28+ const MIN_DATE_MS = - 8640000000000000n
29+ const MAX_DATE_MS = 8640000000000000n
30+
531class Collector {
632 /** @param {{ port?: number, outputDir?: string } } [options] */
733 constructor ( options = { } ) {
@@ -41,9 +67,235 @@ class Collector {
4167 * @param {unknown } data
4268 */
4369 handleData ( signal , data ) {
44- const filePath = path . join ( this . outputDir , `${ signal } .jsonl` )
45- fs . appendFileSync ( filePath , JSON . stringify ( data ) + '\n' )
70+ writeSignalPayload ( this . outputDir , signal , data )
71+ if ( signal === 'logs' ) {
72+ writeNormalizedLogs ( this . outputDir , data )
73+ }
4674 }
4775}
4876
4977export { Collector }
78+
79+ /**
80+ * @returns {string }
81+ */
82+ function todayUtc ( ) {
83+ return new Date ( ) . toISOString ( ) . slice ( 0 , 10 )
84+ }
85+
86+ /**
87+ * Write the raw OTLP payload for a signal.
88+ *
89+ * @param {string } outputDir
90+ * @param {string } signal
91+ * @param {unknown } data
92+ * @returns {void }
93+ */
94+ function writeSignalPayload ( outputDir , signal , data ) {
95+ const signalDir = path . join ( outputDir , signal )
96+ ensureDir ( signalDir )
97+ const filePath = path . join ( signalDir , `${ todayUtc ( ) } .jsonl` )
98+ fs . appendFileSync ( filePath , JSON . stringify ( data ) + '\n' )
99+ }
100+
101+ /**
102+ * Flatten OTLP logs into one JSON row per log record, partitioned by service.
103+ *
104+ * @param {string } outputDir
105+ * @param {unknown } data
106+ * @returns {void }
107+ */
108+ function writeNormalizedLogs ( outputDir , data ) {
109+ const rows = flattenOtlpLogs ( data )
110+ for ( const row of rows ) {
111+ const serviceName = sanitizePathSegment ( row . serviceName || '_unknown' )
112+ const serviceDir = path . join ( outputDir , 'logs-by-service' , serviceName )
113+ ensureDir ( serviceDir )
114+ const filePath = path . join ( serviceDir , `${ todayUtc ( ) } .jsonl` )
115+ fs . appendFileSync ( filePath , JSON . stringify ( row ) + '\n' )
116+ }
117+ }
118+
119+ /**
120+ * @param {string } dir
121+ * @returns {void }
122+ */
123+ function ensureDir ( dir ) {
124+ if ( ! fs . existsSync ( dir ) ) {
125+ fs . mkdirSync ( dir , { recursive : true } )
126+ }
127+ }
128+
129+ /**
130+ * @param {unknown } value
131+ * @returns {Record<string, unknown> | undefined }
132+ */
133+ function objectRecord ( value ) {
134+ if ( ! value || typeof value !== 'object' || Array . isArray ( value ) ) return undefined
135+ return { ...value }
136+ }
137+
138+ /**
139+ * Flatten OTLP log export envelopes into one normalized row per log record.
140+ *
141+ * @param {unknown } data
142+ * @returns {NormalizedLogRow[] }
143+ */
144+ function flattenOtlpLogs ( data ) {
145+ const payload = objectRecord ( data )
146+ const resourceLogs = Array . isArray ( payload ?. resourceLogs ) ? payload . resourceLogs : [ ]
147+ /** @type {NormalizedLogRow[] } */
148+ const rows = [ ]
149+
150+ for ( const resourceLog of resourceLogs ) {
151+ const resourceLogObj = objectRecord ( resourceLog ) ?? { }
152+ const resource = objectRecord ( resourceLogObj . resource )
153+ const resourceAttrs = attrsToObject ( resource ?. attributes )
154+ const serviceName = stringValue ( resourceAttrs [ 'service.name' ] ) || '_unknown'
155+ const scopeLogs = Array . isArray ( resourceLogObj . scopeLogs ) ? resourceLogObj . scopeLogs : [ ]
156+
157+ for ( const scopeLog of scopeLogs ) {
158+ const scopeLogObj = objectRecord ( scopeLog ) ?? { }
159+ const scope = objectRecord ( scopeLogObj . scope ) ?? { }
160+ const logRecords = Array . isArray ( scopeLogObj . logRecords ) ? scopeLogObj . logRecords : [ ]
161+
162+ for ( const logRecord of logRecords ) {
163+ const logRecordObj = objectRecord ( logRecord ) ?? { }
164+ const attributes = attrsToObject ( logRecordObj . attributes )
165+ rows . push ( {
166+ serviceName,
167+ timestamp : otlpTimestampToIso ( logRecordObj . timeUnixNano ) ,
168+ observedTimestamp : otlpTimestampToIso ( logRecordObj . observedTimeUnixNano ) ,
169+ severityNumber : numberValue ( logRecordObj . severityNumber ) ,
170+ severityText : stringValue ( logRecordObj . severityText ) ,
171+ body : anyValue ( logRecordObj . body ) ,
172+ traceId : stringValue ( logRecordObj . traceId ) ,
173+ spanId : stringValue ( logRecordObj . spanId ) ,
174+ flags : numberValue ( logRecordObj . flags ) ,
175+ droppedAttributesCount : numberValue ( logRecordObj . droppedAttributesCount ) ,
176+ resource : resourceAttrs ,
177+ scope : {
178+ name : stringValue ( scope ?. name ) ,
179+ version : stringValue ( scope ?. version ) ,
180+ attributes : attrsToObject ( scope ?. attributes ) ,
181+ } ,
182+ attributes,
183+ } )
184+ }
185+ }
186+ }
187+
188+ return rows
189+ }
190+
191+ /**
192+ * Convert OTLP KeyValue[] into a plain object.
193+ *
194+ * @param {unknown } attrs
195+ * @returns {Record<string, unknown> }
196+ */
197+ function attrsToObject ( attrs ) {
198+ if ( ! Array . isArray ( attrs ) ) return { }
199+ /** @type {Record<string, unknown> } */
200+ const result = { }
201+ for ( const attr of attrs ) {
202+ const pair = objectRecord ( attr )
203+ if ( ! pair ) continue
204+ const key = stringValue ( pair . key )
205+ if ( ! key ) continue
206+ result [ key ] = anyValue ( pair . value )
207+ }
208+ return result
209+ }
210+
211+ /**
212+ * Convert an OTLP AnyValue into a JS value.
213+ *
214+ * @param {unknown } value
215+ * @returns {unknown }
216+ */
217+ function anyValue ( value ) {
218+ const anyVal = objectRecord ( value )
219+ if ( ! anyVal ) return value ?? null
220+ if ( 'stringValue' in anyVal ) return anyStringValue ( anyVal . stringValue )
221+ if ( 'boolValue' in anyVal ) return Boolean ( anyVal . boolValue )
222+ if ( 'intValue' in anyVal ) return numberLike ( anyVal . intValue )
223+ if ( 'doubleValue' in anyVal ) return numberValue ( anyVal . doubleValue )
224+ if ( 'bytesValue' in anyVal ) return anyStringValue ( anyVal . bytesValue )
225+ if ( 'arrayValue' in anyVal ) {
226+ const arrayValue = objectRecord ( anyVal . arrayValue )
227+ const values = Array . isArray ( arrayValue ?. values ) ? arrayValue . values : [ ]
228+ return values . map ( anyValue )
229+ }
230+ if ( 'kvlistValue' in anyVal ) {
231+ return attrsToObject ( objectRecord ( anyVal . kvlistValue ) ?. values )
232+ }
233+ return null
234+ }
235+
236+ /**
237+ * @param {unknown } value
238+ * @returns {string | undefined }
239+ */
240+ function anyStringValue ( value ) {
241+ return typeof value === 'string' ? value : undefined
242+ }
243+
244+ /**
245+ * @param {unknown } value
246+ * @returns {string | undefined }
247+ */
248+ function stringValue ( value ) {
249+ return typeof value === 'string' && value . length > 0 ? value : undefined
250+ }
251+
252+ /**
253+ * @param {unknown } value
254+ * @returns {number | undefined }
255+ */
256+ function numberValue ( value ) {
257+ return typeof value === 'number' && Number . isFinite ( value ) ? value : undefined
258+ }
259+
260+ /**
261+ * @param {unknown } value
262+ * @returns {number | string | undefined }
263+ */
264+ function numberLike ( value ) {
265+ if ( typeof value === 'number' && Number . isFinite ( value ) ) return value
266+ if ( typeof value === 'string' && value . length > 0 ) return value
267+ }
268+
269+ /**
270+ * Convert OTLP nanoseconds-since-epoch into ISO 8601.
271+ *
272+ * @param {unknown } value
273+ * @returns {string | undefined }
274+ */
275+ function otlpTimestampToIso ( value ) {
276+ if ( typeof value !== 'string' && typeof value !== 'number' ) return undefined
277+ if ( typeof value === 'number' && ( ! Number . isFinite ( value ) || ! Number . isInteger ( value ) ) ) return undefined
278+ let asBigInt
279+ try {
280+ asBigInt = BigInt ( value )
281+ } catch {
282+ return undefined
283+ }
284+ const ms = asBigInt / OTLP_NS_PER_MS
285+ if ( ms < MIN_DATE_MS || ms > MAX_DATE_MS ) return undefined
286+ return new Date ( Number ( ms ) ) . toISOString ( )
287+ }
288+
289+ /**
290+ * Map service names to safe directory names without hiding the original value.
291+ *
292+ * @param {string } value
293+ * @returns {string }
294+ */
295+ function sanitizePathSegment ( value ) {
296+ const sanitized = value . replace ( / [ \\ / ] / g, '_' ) . trim ( )
297+ if ( ! sanitized ) return '_unknown'
298+ if ( sanitized === '.' ) return '_dot'
299+ if ( sanitized === '..' ) return '_dotdot'
300+ return sanitized
301+ }
0 commit comments