Skip to content

Commit 79a907c

Browse files
authored
Merge pull request #2 from hyparam/feat/service-normalized-logs
feat: normalize logs by service
2 parents 583cc69 + da3475e commit 79a907c

3 files changed

Lines changed: 493 additions & 7 deletions

File tree

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,12 @@ Data is written to JSONL files in the output directory:
6060

6161
```
6262
otel-data/
63-
├── traces.jsonl
64-
├── metrics.jsonl
65-
└── logs.jsonl
63+
├── traces/
64+
│ └── YYYY-MM-DD.jsonl
65+
├── metrics/
66+
│ └── YYYY-MM-DD.jsonl
67+
└── logs/
68+
└── YYYY-MM-DD.jsonl
6669
```
6770

6871
Each line is a JSON object representing the received payload.
@@ -77,7 +80,7 @@ node bin/cli.js
7780
curl -X POST localhost:4318/v1/traces -H 'Content-Type: application/json' -d '{"test": true}'
7881

7982
# Check output
80-
cat otel-data/traces.jsonl
83+
cat otel-data/traces/$(date -u +%F).jsonl
8184
```
8285

8386
## License

src/collector.js

Lines changed: 254 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,32 @@ import fs from 'node:fs'
22
import path from 'node:path'
33
import { 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+
531
class 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

4977
export { 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

Comments
 (0)