import { Injectable } from '@nestjs/common';
type RouteMetric={
dataOrigin:'production'|'synthetic';
count:number;
errors:number;
totalMs:number;
maxMs:number;
buckets:Record<string,number>;
};
@Injectable()
export class ObservabilityService {
private readonly startedAt=Date.now();
private readonly routes=new Map<string,RouteMetric>();
recordHttp(input:{method:string;route:string;status:number;durationMs:number;dataOrigin?:'production'|'synthetic'}) {
const dataOrigin=input.dataOrigin??'production';
if(dataOrigin!=='production'&&dataOrigin!=='synthetic') throw new Error('observability.data_origin_invalid');
const key=`${dataOrigin} ${input.method} ${this.normalize(input.route)}`;
const m=this.routes.get(key)??{
dataOrigin,
count:0,errors:0,totalMs:0,maxMs:0,
buckets:{'50':0,'100':0,'250':0,'500':0,'1000':0,'2500':0,'inf':0},
};
m.count++;
if(input.status>=500) m.errors++;
m.totalMs+=input.durationMs;
m.maxMs=Math.max(m.maxMs,input.durationMs);
let placed=false;
for(const b of [50,100,250,500,1000,2500]) {
if(input.durationMs<=b) {
m.buckets[String(b)]++;
placed=true;
break;
}
}
if(!placed) m.buckets.inf++;
this.routes.set(key,m);
}
snapshot() {
return {
uptimeSeconds:Math.floor((Date.now()-this.startedAt)/1000),
routes:[...this.routes.entries()].map(([route,m])=>({
route,
dataOrigin:m.dataOrigin,
count:m.count,
errors:m.errors,
errorRate:m.count?m.errors/m.count:0,
averageMs:m.count?Math.round((m.totalMs/m.count)*100)/100:0,
maxMs:m.maxMs,
buckets:m.buckets,
})),
};
}
prometheus() {
const out:string[]=[
'# HELP gastro_os_uptime_seconds Process uptime',
'# TYPE gastro_os_uptime_seconds gauge',
`gastro_os_uptime_seconds ${Math.floor((Date.now()-this.startedAt)/1000)}`,
];
for(const [route,m] of this.routes) {
const label=route.replace(/\\/g,'\\\\').replace(/"/g,'\\\"');
const origin=m.dataOrigin.replace(/\\/g,'\\\\').replace(/"/g,'\\"');
out.push(`gastro_os_http_requests_total{route="${label}",data_origin="${origin}"} ${m.count}`);
out.push(`gastro_os_http_5xx_total{route="${label}",data_origin="${origin}"} ${m.errors}`);
out.push(`gastro_os_http_duration_ms_sum{route="${label}",data_origin="${origin}"} ${m.totalMs}`);
out.push(`gastro_os_http_duration_ms_max{route="${label}",data_origin="${origin}"} ${m.maxMs}`);
}
return `${out.join('\n')}\n`;
}
private normalize(path:string) {
return path
.replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi,':id')
.replace(/\/\d+(?=\/|$)/g,'/:id')
.slice(0,200);
}
}
