mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
* Revert "revert: roll source tree back to v1.4.0 to restore production"
This reverts commit e444a90b10.
* fix(v1.4.2): summaries.WEIGHT + medications array guards + healthcheck IPv4
Three production bugs surfaced after the v1.4.0 deploy stabilized:
1. `TypeError: undefined is not an object (evaluating 'E?.summaries.WEIGHT')`
on /dashboard and /insights for users without weight readings —
the optional chain stopped at `data` when it needed to also cover
`data.summaries`. Fixed: `data?.summaries?.WEIGHT` (one level deeper).
2. `TypeError: y?.filter is not a function` on /medications — the
medications query value was non-array (likely a stale
service-worker response from an older API shape). Replaced with
`Array.isArray(medications) ? medications : []` so the page is
immune to any future shape drift.
3. Healthcheck-induced 503 outage (already in 1.4.1 — included here
so 1.4.2 is the single coherent release). The docker-compose
healthcheck used `localhost` which busybox-wget resolves to IPv6
::1; Next.js standalone listens on IPv4 0.0.0.0 only. Pinned
to 127.0.0.1.
This release also includes everything from v1.4.1 (which never
published a clean GHCR tag): the per-section admin extraction, the
testcontainers integration suite, the Playwright + axe-core E2E
foundation, the moodLog SSRF fix, the central error-redaction in
WideEventBuilder + Glitchtip, the medical-citation drift fixes
(ESH 2023, Saint-Maurice attribution, steps source), and the docs
sync.
Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
152 lines
3.8 KiB
TypeScript
152 lines
3.8 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type { WideEvent, LogLevel, EventKind } from "./types";
|
|
import { LOG_LEVEL_PRIORITY } from "./types";
|
|
import { getDeployContext } from "./config";
|
|
import { redactOptional, redactSecrets } from "./redact";
|
|
|
|
/**
|
|
* Baut ein Wide Event Schritt fuer Schritt auf.
|
|
* Wird am Anfang eines Requests/Tasks erstellt und am Ende emittiert.
|
|
*/
|
|
export class WideEventBuilder {
|
|
private event: Partial<WideEvent>;
|
|
private startTime: number;
|
|
private highestLevel: LogLevel = "info";
|
|
|
|
constructor(kind: EventKind = "http") {
|
|
this.startTime = performance.now();
|
|
this.event = {
|
|
timestamp: new Date().toISOString(),
|
|
request_id: randomUUID(),
|
|
trace_id: randomUUID(),
|
|
kind,
|
|
service: "healthlog",
|
|
environment: process.env.NODE_ENV || "development",
|
|
deploy: getDeployContext(),
|
|
level: "info",
|
|
};
|
|
}
|
|
|
|
setRequestId(id: string): this {
|
|
this.event.request_id = id;
|
|
return this;
|
|
}
|
|
|
|
setTraceId(id: string): this {
|
|
this.event.trace_id = id;
|
|
return this;
|
|
}
|
|
|
|
setHttp(http: WideEvent["http"]): this {
|
|
this.event.http = http;
|
|
return this;
|
|
}
|
|
|
|
setAuth(auth: WideEvent["auth"]): this {
|
|
this.event.auth = auth;
|
|
return this;
|
|
}
|
|
|
|
setAction(action: WideEvent["action"]): this {
|
|
this.event.action = action;
|
|
return this;
|
|
}
|
|
|
|
setBackground(bg: WideEvent["background"]): this {
|
|
this.event.background = bg;
|
|
return this;
|
|
}
|
|
|
|
setError(err: unknown): this {
|
|
this.elevateLevel("error");
|
|
if (err instanceof Error) {
|
|
this.event.error = {
|
|
type: err.constructor.name,
|
|
message: redactSecrets(err.message),
|
|
stack: redactOptional(err.stack),
|
|
code: (err as { statusCode?: number }).statusCode,
|
|
};
|
|
} else {
|
|
this.event.error = {
|
|
type: "Unknown",
|
|
message: redactSecrets(String(err)),
|
|
};
|
|
}
|
|
return this;
|
|
}
|
|
|
|
addWarning(msg: string): this {
|
|
this.elevateLevel("warn");
|
|
if (!this.event.warnings) this.event.warnings = [];
|
|
this.event.warnings.push(msg);
|
|
return this;
|
|
}
|
|
|
|
addExternalCall(call: NonNullable<WideEvent["external_calls"]>[0]): this {
|
|
if (!this.event.external_calls) this.event.external_calls = [];
|
|
this.event.external_calls.push(call);
|
|
return this;
|
|
}
|
|
|
|
addDbQuery(durationMs: number): this {
|
|
if (!this.event.db)
|
|
this.event.db = { query_count: 0, query_duration_ms: 0 };
|
|
this.event.db.query_count++;
|
|
this.event.db.query_duration_ms += durationMs;
|
|
return this;
|
|
}
|
|
|
|
setRateLimit(info: WideEvent["rate_limit"]): this {
|
|
this.event.rate_limit = info;
|
|
return this;
|
|
}
|
|
|
|
addMeta(key: string, value: unknown): this {
|
|
if (!this.event.meta) this.event.meta = {};
|
|
this.event.meta[key] = value;
|
|
return this;
|
|
}
|
|
|
|
/** Level nur hochstufen, nie runter */
|
|
elevateLevel(level: LogLevel): this {
|
|
if (LOG_LEVEL_PRIORITY[level] > LOG_LEVEL_PRIORITY[this.highestLevel]) {
|
|
this.highestLevel = level;
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/** Request/Operation beenden, Duration berechnen */
|
|
finish(httpStatus?: number): this {
|
|
this.event.duration_ms = Math.round(performance.now() - this.startTime);
|
|
if (httpStatus !== undefined && this.event.http) {
|
|
this.event.http.status = httpStatus;
|
|
}
|
|
if (httpStatus !== undefined) {
|
|
if (httpStatus >= 500) this.elevateLevel("error");
|
|
else if (httpStatus >= 400) this.elevateLevel("warn");
|
|
}
|
|
this.event.level = this.highestLevel;
|
|
return this;
|
|
}
|
|
|
|
toJSON(): WideEvent {
|
|
return structuredClone(this.event) as WideEvent;
|
|
}
|
|
|
|
getDurationMs(): number {
|
|
return Math.round(performance.now() - this.startTime);
|
|
}
|
|
|
|
getLevel(): LogLevel {
|
|
return this.highestLevel;
|
|
}
|
|
|
|
getRequestId(): string {
|
|
return this.event.request_id!;
|
|
}
|
|
|
|
getTraceId(): string {
|
|
return this.event.trace_id!;
|
|
}
|
|
}
|