@trifrost/core
Version:
Blazingly fast, runtime-agnostic server framework for modern edge and node environments
233 lines (232 loc) • 8.18 kB
JavaScript
import { deepFreeze } from '@valkyriestudios/utils/deep';
import { sleep } from '@valkyriestudios/utils/function';
import { isIntGt } from '@valkyriestudios/utils/number';
import { createScrambler, OMIT_PRESETS } from '../../../utils/Scrambler';
const LEVELSMAP = {
debug: 'DEBUG',
error: 'ERROR',
info: 'INFO',
log: 'LOG',
warn: 'WARN',
};
function convertObjectToAttributes(obj, prefix = '') {
const acc = [];
for (const key in obj) {
const val = obj[key];
switch (typeof val) {
case 'string':
acc.push({ key: prefix + key, value: { stringValue: val } });
break;
case 'number':
if (Number.isFinite(val)) {
acc.push({ key: prefix + key, value: Number.isInteger(val) ? { intValue: val } : { doubleValue: val } });
}
break;
case 'boolean':
acc.push({ key: prefix + key, value: { boolValue: val } });
break;
default:
if (Object.prototype.toString.call(val) === '[object Object]' || Array.isArray(val)) {
acc.push({ key: prefix + key, value: { stringValue: JSON.stringify(val) } });
}
break;
}
}
return acc;
}
export class OtelHttpExporter {
logEndpoint;
spanEndpoint;
headers;
/**
* Internal buffer for logs
*/
buffer = [];
/**
* Internal buffer for spans
*/
spanBuffer = [];
/**
* Max size per batch that we send through to source system
*/
maxBatchSize = 20;
/**
* Max internal buffer size
*/
maxBufferSize = 10_000;
/**
* Max retries when sending batch to source system
*/
maxRetries = 3;
resourceAttributes = [];
/**
* Scrambler based on omit pattern provided
*/
scramble;
constructor(options) {
this.logEndpoint = options.logEndpoint;
this.spanEndpoint = options.spanEndpoint || options.logEndpoint;
this.headers = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
/* Configure max batch size */
if (isIntGt(options.maxBatchSize, 0))
this.maxBatchSize = options.maxBatchSize;
/* Configure max buffer size */
if (isIntGt(options.maxBufferSize, 0))
this.maxBufferSize = options.maxBufferSize;
/* Cap max batch size to max buffer size */
if (this.maxBatchSize > this.maxBufferSize)
this.maxBatchSize = this.maxBufferSize;
/* Configure max retries */
if (isIntGt(options.maxRetries, 0))
this.maxRetries = options.maxRetries;
/* Configure scrambler */
this.scramble = createScrambler({
checks: Array.isArray(options?.omit) ? deepFreeze([...options.omit]) : OMIT_PRESETS.default,
});
}
init(trifrost) {
this.resourceAttributes = convertObjectToAttributes(this.scramble(trifrost));
}
async pushLog(log) {
this.buffer.push(log);
if (this.buffer.length >= this.maxBatchSize)
await this.flushLogs();
}
async pushSpan(span) {
this.spanBuffer.push(span);
if (this.spanBuffer.length >= this.maxBatchSize)
await this.flushSpans();
}
async flush() {
await Promise.all([this.flushLogs(), this.flushSpans()]);
}
/**
* Flushes Otel Logs
*/
async flushLogs() {
if (this.buffer.length === 0)
return;
/* swap out buffer */
const batch = this.buffer;
this.buffer = [];
/* Convert logs */
const logRecords = [];
for (let i = 0; i < batch.length; i++) {
const log = batch[i];
/* Scramble sensitive values and convert to attributes */
const attributes = [
...(log.ctx ? convertObjectToAttributes(this.scramble(log.ctx), 'ctx.') : []),
...(log.data ? convertObjectToAttributes(this.scramble(log.data), 'data.') : []),
];
if (log.trace_id)
attributes.push({ key: 'trace_id', value: { stringValue: log.trace_id } });
if (log.span_id)
attributes.push({ key: 'span_id', value: { stringValue: log.span_id } });
logRecords.push({
timeUnixNano: log.time.getTime() * 1_000_000,
severityText: LEVELSMAP[log.level],
body: this.scramble({ stringValue: log.message }),
attributes,
});
}
const success = await this.sendWithRetry(this.logEndpoint, {
resourceLogs: [
{
resource: {
attributes: this.resourceAttributes,
},
scopeLogs: [
{
scope: { name: 'trifrost.logger', version: '1.0.0' },
logRecords,
},
],
},
],
});
/* If failed, requeue batch */
if (!success) {
const newSize = this.buffer.length + batch.length;
/* Only add if new size does not go over buffer max size */
if (newSize <= this.maxBufferSize)
this.buffer.unshift(...batch);
}
}
/**
* Flushes Otel Spans
*/
async flushSpans() {
if (this.spanBuffer.length === 0)
return;
/* swap out buffer */
const batch = this.spanBuffer;
this.spanBuffer = [];
/* Convert to otel format */
const otelSpans = [];
for (let i = 0; i < batch.length; i++) {
const span = batch[i];
otelSpans.push({
name: span.name,
traceId: span.traceId,
spanId: span.spanId,
startTimeUnixNano: span.start * 1_000_000,
endTimeUnixNano: span.end * 1_000_000,
attributes: convertObjectToAttributes(this.scramble(span.ctx)),
...(span.parentSpanId && { parentSpanId: span.parentSpanId }),
...(span.status && { status: span.status }),
});
}
const success = await this.sendWithRetry(this.spanEndpoint, {
resourceSpans: [
{
resource: {
attributes: this.resourceAttributes,
},
scopeSpans: [
{
scope: { name: 'trifrost.logger', version: '1.0.0' },
spans: otelSpans,
},
],
},
],
});
/* If failed, requeue batch */
if (!success) {
const newSize = this.spanBuffer.length + batch.length;
/* Only add if new size does not go over buffer max size */
if (newSize <= this.maxBufferSize)
this.spanBuffer.unshift(...batch);
}
}
async sendWithRetry(endpoint, body) {
let attempt = 0;
let delay = 100;
while (attempt < this.maxRetries) {
try {
const res = await globalThis.fetch(endpoint, {
method: 'POST',
headers: this.headers,
body: JSON.stringify(body),
});
if (res.ok)
return true;
throw new Error(`Transport received HTTP ${res.status}`);
}
catch (err) {
attempt++;
if (attempt >= this.maxRetries) {
console.error('[Logger] Transport failed after retries', err);
return false; /* We return true here to prevent memory overflows */
}
/* Jittered exponential backoff */
const jitter = delay * 0.5 * Math.random();
await sleep(delay + jitter);
delay *= 2;
}
}
}
}