Files
safetynet/apps/server/telemetry.mjs
T

53 lines
5.2 KiB
JavaScript

import inspectionStages from '../../packages/core/inspection-stages.cjs';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { trace, metrics,context,ROOT_CONTEXT,SpanStatusCode } from '@opentelemetry/api';
import { logs } from '@opentelemetry/api-logs';
import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
export function startTelemetry() {
if(!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) return null;
const endpoint=new URL(process.env.OTEL_EXPORTER_OTLP_ENDPOINT);
if(endpoint.username||endpoint.password||endpoint.search||endpoint.hash||!(endpoint.protocol==='https:'||endpoint.protocol==='http:'&&['127.0.0.1','[::1]'].includes(endpoint.hostname)))throw Error('OTLP requires HTTPS or numeric loopback without URL credentials');
const sdk = new NodeSDK({serviceName:'safetynet-control-plane',traceExporter:new OTLPTraceExporter(),
metricReaders:[new PeriodicExportingMetricReader({exporter:new OTLPMetricExporter(),exportIntervalMillis:10000})],
logRecordProcessors:[new BatchLogRecordProcessor({exporter:new OTLPLogExporter()})]});
sdk.start(); return sdk;
}
export const tracer=trace.getTracer('safetynet','0.1.0');
const stages=new Set(['evidence.challenge','evidence.decrypt','evidence.store','evidence.encrypt','audit.verify','startpage.news','ai.gateway','ai.inspect.input','ai.inspect.output','ai.provider.request','ai.provider.read','ai.provider.decode','library.propose','library.validate','library.apply','library.derive','server.request']);
export async function traceOperation(name,attributes,fn){
if(typeof fn!=='function')throw TypeError('Trace operation requires a function');
if(!stages.has(name))return fn();
const safe={};for(const [key,value]of Object.entries(attributes||{}))if(['bytes','count','policyVersion'].includes(key)&&Number.isSafeInteger(value)&&value>=0)safe[`safetynet.${key}`]=value;
return tracer.startActiveSpan(name,{attributes:safe},async span=>{const start=performance.now();try{return await fn();}catch(error){span.setStatus({code:SpanStatusCode.ERROR});span.setAttribute('error.type',error instanceof TypeError?'TypeError':error instanceof RangeError?'RangeError':'Error');throw error;}finally{span.setAttribute('safetynet.duration.ms',performance.now()-start);span.end();}});
}
export function installRequestTelemetry(app){
app.use((req,res,next)=>{
if(!req.path.startsWith('/api/'))return next();
const match=/^00-([a-f0-9]{32})-([a-f0-9]{16})-(0[01])$/.exec(req.headers.traceparent||'');
const parent=match&&match[1]!=='0'.repeat(32)&&match[2]!=='0'.repeat(16)?trace.setSpanContext(ROOT_CONTEXT,{traceId:match[1],spanId:match[2],traceFlags:parseInt(match[3],16),isRemote:true}):ROOT_CONTEXT;
const span=tracer.startSpan('server.request',{attributes:{'http.request.method':['GET','POST','PUT','PATCH','DELETE','OPTIONS'].includes(req.method)?req.method:'OTHER'}},parent),start=performance.now();let ended=false;
function end(){if(ended)return;ended=true;const route=req.route?.path;if(typeof route==='string'&&route.length<=120)span.setAttribute('http.route',route);span.setAttribute('http.response.status_code',res.statusCode);span.setAttribute('safetynet.duration.ms',performance.now()-start);if(res.statusCode>=500)span.setStatus({code:SpanStatusCode.ERROR});span.end();}
res.once('finish',end);res.once('close',end);context.with(trace.setSpan(parent,span),next);
});
}
export function recordSecurityEvent(kind,metadata,eventId){
// Only bounded, allowlisted fields. Never emit body text, rules, vectors or prompts.
const decision=metadata.decision??(kind.includes('block')?'block':'other');
metrics.getMeter('safetynet').createCounter('safetynet.security.events').add(1,{'event.action':kind,'dlp.decision':decision});
logs.getLogger('safetynet.security').emit({severityNumber:kind.includes('block')?13:9,severityText:kind.includes('block')?'WARN':'INFO',body:kind,
attributes:{'event.id':eventId,'event.action':kind,'dlp.decision':decision,...(metadata.ruleId?{'rule.id':metadata.ruleId}:{}),...(metadata.policyVersion?{'safetynet.policy.version':metadata.policyVersion}:{})}});
}
export function importInspectionStages(envelope,policy){
try{
if(policy?.diagnostics?.enabled!==true||policy.diagnostics.mode!=='debug')return;
const parent=context.active(),active=trace.getSpan(parent);if(!active?.isRecording())return;
const batch=inspectionStages.validateStages(envelope);if(!batch)return;
const parents=new Map();for(const row of batch.records){const scope=row.parentId===null?parent:parents.get(row.parentId);if(!scope)return;const span=tracer.startSpan('inspector.'+row.stage,{startTime:batch.baseUnixMs+row.startOffsetMs,attributes:{'safetynet.component':'inspector','safetynet.status':row.status,'safetynet.duration.ms':row.durationMs}},scope);if(row.status==='error')span.setStatus({code:SpanStatusCode.ERROR});parents.set(row.id,trace.setSpan(scope,span));span.end(batch.baseUnixMs+row.startOffsetMs+row.durationMs);}
}catch{/* Diagnostics cannot change a DLP decision. */}
}