69a519be75
CloudWatch gav loggar och mätvärden men svarade inte på frågan man faktiskt har när något är långsamt: var tog tiden vägen. Tjänsterna har medvetet nästan inga beroenden — plattformen har pg-drivrutinen, orkestern har Claude-klienten. Att dra in ett OpenTelemetry-SDK med trettio paket för att mäta fyra saker vore fel avvägning. I stället två standarder som båda bara är text på stdout: W3C Trace Context. Klienten startar spåret och traceparent följer med genom plattformen till orkestern, så en teknikers handling går att följa hela vägen till modellsvaret i stället för att bli två orelaterade spår. CloudWatch EMF. Strukturerad JSON som CloudWatch själv extraherar mätvärden ur — ingen agent, ingen SDK, inget som kan sluta fungera tyst. Varje anrop ger en loggrad med nedbrytning av tiden per del: databasen, modellanropet, objektlagringen, kundens leverantör. Det svarar direkt på om ett långsamt ärende beror på S3 eller på Opus-granskningen, i stället för att någon ska korrelera fem loggrader. Vägen normaliseras innan den blir dimension, och organisation, ärende-id och spår-id blir aldrig dimensioner — varje unik kombination är en egen tidsserie som kostar. De ligger som vanliga fält, sökbara i Logs Insights. Ett test låser det, eftersom det är precis den sortens sak som smyger in senare. Tre larm på det teknikern märker: svarstid p95 över tre sekunder (medelvärdet döljer att var tjugonde tekniker väntar orimligt länge), serverfel med spår-id i loggraden, och att modellen avböjer — det senare tyder på att underlaget innehåller något oväntat, inte på ett driftfel. Modulen är delad mellan tjänsterna i stället för duplicerad. Byggkontexten flyttas därför till felsokning/services, och en symlänk gör att testerna och integrationstestet kör mot samma fil som bilderna. Verifierat: 106 vitest-tester (10 nya för spårning, EMF-format och att dimensionerna hålls få), typkontroll, eslint på klient och tjänster, rotens CI, terraform fmt och referenskontroll på båda lagren, samt integrationstest mot riktig Postgres där spårraderna syns live. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
167 lines
5.2 KiB
JavaScript
167 lines
5.2 KiB
JavaScript
// Observation — spårning och mätvärden utan nya beroenden.
|
|
//
|
|
// Tjänsterna har medvetet nästan inga beroenden: plattformen har
|
|
// pg-drivrutinen, orkestern har Claude-klienten. Att dra in ett
|
|
// OpenTelemetry-SDK med trettio paket för att mäta fyra saker vore fel
|
|
// avvägning.
|
|
//
|
|
// I stället två standarder som båda bara är text på stdout:
|
|
//
|
|
// W3C Trace Context traceparent-huvudet följer med genom hela
|
|
// kedjan, så en teknikers anrop går att följa
|
|
// från klienten via plattformen till orkestern.
|
|
//
|
|
// CloudWatch EMF strukturerad JSON som CloudWatch själv plockar
|
|
// mätvärden ur. Ingen agent, ingen SDK, inget
|
|
// som kan sluta fungera tyst.
|
|
//
|
|
// Det som mäts är valt efter en fråga: vad vill man veta klockan tre på
|
|
// natten när något är långsamt? Svaret är var tiden gick — inte hur
|
|
// många anrop som skett.
|
|
|
|
import { randomBytes } from "node:crypto";
|
|
|
|
export const NAMNRYMD = "GuidadFelsokning";
|
|
|
|
const TRACEPARENT = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
|
|
/**
|
|
* Läser inkommande traceparent eller startar ett nytt spår.
|
|
* Ett anrop utan huvud är inte ett fel — de flesta kommer utifrån.
|
|
*/
|
|
export function spårFrån(huvud) {
|
|
const träff = typeof huvud === "string" ? huvud.trim().match(TRACEPARENT) : null;
|
|
return {
|
|
spårId: träff ? träff[1] : randomBytes(16).toString("hex"),
|
|
förälder: träff ? träff[2] : null,
|
|
spanId: randomBytes(8).toString("hex"),
|
|
};
|
|
}
|
|
|
|
// Skickas vidare till nästa tjänst i kedjan.
|
|
export function traceparent(spår) {
|
|
return `00-${spår.spårId}-${spår.spanId}-01`;
|
|
}
|
|
|
|
/**
|
|
* Ett spann. Mäter tid och samlar barn, så att en logg-rad kan svara på
|
|
* "var tog tiden vägen" utan att någon behöver korrelera flera rader.
|
|
*/
|
|
export function starta(namn, spår) {
|
|
const början = process.hrtime.bigint();
|
|
const barn = [];
|
|
|
|
return {
|
|
namn,
|
|
spår,
|
|
|
|
// Tidtagning på det som faktiskt kan vara långsamt: databasen,
|
|
// modellanropet, objektlagringen, kundens leverantör.
|
|
async mät(delnamn, arbete) {
|
|
const start = process.hrtime.bigint();
|
|
try {
|
|
return await arbete();
|
|
} finally {
|
|
barn.push({ namn: delnamn, ms: Number(process.hrtime.bigint() - start) / 1e6 });
|
|
}
|
|
},
|
|
|
|
ms() {
|
|
return Number(process.hrtime.bigint() - början) / 1e6;
|
|
},
|
|
|
|
delar() {
|
|
// Samma delnamn flera gånger (t.ex. tre databasfrågor) slås ihop:
|
|
// antalet och summan säger mer än en lista.
|
|
const summa = {};
|
|
for (const d of barn) {
|
|
summa[d.namn] ??= { antal: 0, ms: 0 };
|
|
summa[d.namn].antal += 1;
|
|
summa[d.namn].ms += d.ms;
|
|
}
|
|
for (const d of Object.values(summa)) d.ms = Math.round(d.ms * 100) / 100;
|
|
return summa;
|
|
},
|
|
};
|
|
}
|
|
|
|
// En rad JSON per händelse. CloudWatch Logs Insights kan fråga på
|
|
// vilket fält som helst utan att någon behöver skriva ett regex.
|
|
export function logga(nivå, meddelande, fält = {}) {
|
|
process.stdout.write(
|
|
`${JSON.stringify({
|
|
nivå,
|
|
meddelande,
|
|
tid: new Date().toISOString(),
|
|
...fält,
|
|
})}\n`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Mätvärden i CloudWatch Embedded Metric Format.
|
|
*
|
|
* Formatet är en logg-rad som CloudWatch känner igen och extraherar
|
|
* mätvärden ur. Poängen är att mätvärdet och sammanhanget står i samma
|
|
* rad: när latensen är hög går det att gå direkt till anropen som
|
|
* orsakade den, i stället för att gissa utifrån en graf.
|
|
*
|
|
* Dimensioner hålls medvetet få. Varje unik kombination är en egen
|
|
* tidsserie som kostar, så organisation eller ärende-id får aldrig bli
|
|
* en dimension — de ligger som vanliga fält.
|
|
*/
|
|
export function mätvärde(namn, värde, enhet, dimensioner = {}, extra = {}) {
|
|
const nycklar = Object.keys(dimensioner);
|
|
|
|
process.stdout.write(
|
|
`${JSON.stringify({
|
|
_aws: {
|
|
Timestamp: Date.now(),
|
|
CloudWatchMetrics: [
|
|
{
|
|
Namespace: NAMNRYMD,
|
|
Dimensions: nycklar.length > 0 ? [nycklar] : [[]],
|
|
Metrics: [{ Name: namn, Unit: enhet }],
|
|
},
|
|
],
|
|
},
|
|
...dimensioner,
|
|
[namn]: värde,
|
|
...extra,
|
|
})}\n`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Avslutar ett spann: en strukturerad logg-rad med hela nedbrytningen,
|
|
* och ett mätvärde för latensen.
|
|
*
|
|
* Fel loggas som fel men mäts på samma ställe — annars syns inte att
|
|
* felen är snabba och de lyckade anropen långsamma, vilket är precis
|
|
* den sortens sak som förvirrar en felsökning.
|
|
*/
|
|
export function avsluta(spann, { status, väg, extra = {} }) {
|
|
const ms = spann.ms();
|
|
const delar = spann.delar();
|
|
|
|
logga(status >= 500 ? "fel" : "info", spann.namn, {
|
|
spårId: spann.spår.spårId,
|
|
spanId: spann.spår.spanId,
|
|
väg,
|
|
status,
|
|
ms: Math.round(ms * 100) / 100,
|
|
delar,
|
|
...extra,
|
|
});
|
|
|
|
mätvärde("Svarstid", ms, "Milliseconds", { Tjänst: spann.namn, Väg: väg }, { status });
|
|
|
|
// En egen serie för fel gör larmet enkelt: larma på summan, inte på
|
|
// ett förhållande som måste räknas ut.
|
|
if (status >= 500) {
|
|
mätvärde("Fel", 1, "Count", { Tjänst: spann.namn, Väg: väg }, { spårId: spann.spår.spårId });
|
|
}
|
|
|
|
return ms;
|
|
}
|