From 764e1588187795f6b5c56921e579e0650de496cd Mon Sep 17 00:00:00 2001 From: ComputerOracle Date: Sat, 30 May 2026 13:20:39 +0000 Subject: [PATCH] task: propagate correlation and stream ids in lifecycle logs - Add logger, privacy (redaction), and correlation-middleware utilities - Integrate structured, correlated, and redacted logging into stream lifecycle routes (start, pause, stop, settle, withdraw) - Add unit tests for new logging infrastructure --- app/api/streams/[id]/pause/route.ts | 16 ++++++++++++++++ app/api/streams/[id]/settle/route.ts | 26 ++++++++++++++++++++++---- app/api/streams/[id]/start/route.ts | 16 ++++++++++++++++ app/api/streams/[id]/stop/route.ts | 16 ++++++++++++++++ app/api/streams/[id]/withdraw/route.ts | 16 ++++++++++++++++ app/lib/correlation-middleware.test.ts | 15 +++++++++++++++ app/lib/correlation-middleware.ts | 11 +++++++++++ app/lib/logger.test.ts | 12 ++++++++++++ app/lib/logger.ts | 11 +++++++++++ app/lib/privacy.test.ts | 21 +++++++++++++++++++++ app/lib/privacy.ts | 13 +++++++++++++ 11 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 app/lib/correlation-middleware.test.ts create mode 100644 app/lib/correlation-middleware.ts create mode 100644 app/lib/logger.test.ts create mode 100644 app/lib/logger.ts create mode 100644 app/lib/privacy.test.ts create mode 100644 app/lib/privacy.ts diff --git a/app/api/streams/[id]/pause/route.ts b/app/api/streams/[id]/pause/route.ts index 2080ae0..d847066 100644 --- a/app/api/streams/[id]/pause/route.ts +++ b/app/api/streams/[id]/pause/route.ts @@ -1,5 +1,8 @@ import { NextResponse } from "next/server"; import { db } from "@/app/lib/db"; +import { logger } from "@/app/lib/logger"; +import { getCorrelationContext } from "@/app/lib/correlation-middleware"; +import { redact } from "@/app/lib/privacy"; function createErrorResponse(code: string, message: string, status: number) { return NextResponse.json({ error: { code, message, request_id: "mock-request-id" } }, { status }); @@ -10,16 +13,29 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; + const correlationId = getCorrelationContext()?.correlationId || "unknown"; + const stream = db.streams.get(id); if (!stream) { + logger.warn("Stream not found for pause action", { correlationId, streamId: id }); return createErrorResponse("STREAM_NOT_FOUND", `Stream '${id}' not found`, 404); } if (stream.status !== "active") { + logger.warn("Invalid stream state for pause action", { correlationId, streamId: id, status: stream.status }); return createErrorResponse("INVALID_STREAM_STATE", "Only active streams can be paused", 409); } stream.status = "paused"; stream.nextAction = "start"; stream.updatedAt = new Date().toISOString(); db.streams.set(id, stream); + + logger.info("Stream paused successfully", { + correlationId, + streamId: id, + action: "pause", + status: "success", + stream: redact(stream) + }); + return NextResponse.json({ data: stream }); } diff --git a/app/api/streams/[id]/settle/route.ts b/app/api/streams/[id]/settle/route.ts index 10de553..4b43f64 100644 --- a/app/api/streams/[id]/settle/route.ts +++ b/app/api/streams/[id]/settle/route.ts @@ -1,5 +1,8 @@ import { NextResponse } from "next/server"; import { db } from "@/app/lib/db"; +import { logger } from "@/app/lib/logger"; +import { getCorrelationContext } from "@/app/lib/correlation-middleware"; +import { redact } from "@/app/lib/privacy"; function createErrorResponse(code: string, message: string, status: number) { return NextResponse.json({ error: { code, message, request_id: "mock-request-id" } }, { status }); @@ -10,24 +13,39 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; + const correlationId = getCorrelationContext()?.correlationId || "unknown"; + const stream = db.streams.get(id); if (!stream) { + logger.warn("Stream not found for settle action", { correlationId, streamId: id }); return createErrorResponse("STREAM_NOT_FOUND", `Stream '${id}' not found`, 404); } if (stream.status !== "active" && stream.status !== "paused") { + logger.warn("Invalid stream state for settle action", { correlationId, streamId: id, status: stream.status }); return createErrorResponse("INVALID_STREAM_STATE", "Only active or paused streams can be settled", 409); } stream.status = "ended"; stream.nextAction = "withdraw"; stream.updatedAt = new Date().toISOString(); db.streams.set(id, stream); + + const settlement = { + txHash: `fake-tx-${crypto.randomUUID().slice(0, 8)}`, + settledAt: new Date().toISOString(), + }; + + logger.info("Stream settled successfully", { + correlationId, + streamId: id, + action: "settle", + status: "success", + stream: redact({ ...stream, settlement }) + }); + return NextResponse.json({ data: { ...stream, - settlement: { - txHash: `fake-tx-${crypto.randomUUID().slice(0, 8)}`, - settledAt: new Date().toISOString(), - }, + settlement, }, }); } diff --git a/app/api/streams/[id]/start/route.ts b/app/api/streams/[id]/start/route.ts index ca3eee9..4886e26 100644 --- a/app/api/streams/[id]/start/route.ts +++ b/app/api/streams/[id]/start/route.ts @@ -1,5 +1,8 @@ import { NextResponse } from "next/server"; import { db } from "@/app/lib/db"; +import { logger } from "@/app/lib/logger"; +import { getCorrelationContext } from "@/app/lib/correlation-middleware"; +import { redact } from "@/app/lib/privacy"; function createErrorResponse(code: string, message: string, status: number) { return NextResponse.json({ error: { code, message, request_id: "mock-request-id" } }, { status }); @@ -10,16 +13,29 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; + const correlationId = getCorrelationContext()?.correlationId || "unknown"; + const stream = db.streams.get(id); if (!stream) { + logger.warn("Stream not found for start action", { correlationId, streamId: id }); return createErrorResponse("STREAM_NOT_FOUND", `Stream '${id}' not found`, 404); } if (stream.status !== "draft") { + logger.warn("Invalid stream state for start action", { correlationId, streamId: id, status: stream.status }); return createErrorResponse("INVALID_STREAM_STATE", "Only draft streams can be started", 409); } stream.status = "active"; stream.nextAction = "pause"; stream.updatedAt = new Date().toISOString(); db.streams.set(id, stream); + + logger.info("Stream started successfully", { + correlationId, + streamId: id, + action: "start", + status: "success", + stream: redact(stream) + }); + return NextResponse.json({ data: stream }); } diff --git a/app/api/streams/[id]/stop/route.ts b/app/api/streams/[id]/stop/route.ts index 35af39e..5eed8f0 100644 --- a/app/api/streams/[id]/stop/route.ts +++ b/app/api/streams/[id]/stop/route.ts @@ -1,5 +1,8 @@ import { NextResponse } from "next/server"; import { db } from "@/app/lib/db"; +import { logger } from "@/app/lib/logger"; +import { getCorrelationContext } from "@/app/lib/correlation-middleware"; +import { redact } from "@/app/lib/privacy"; function createErrorResponse(code: string, message: string, status: number) { return NextResponse.json({ error: { code, message, request_id: "mock-request-id" } }, { status }); @@ -10,16 +13,29 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; + const correlationId = getCorrelationContext()?.correlationId || "unknown"; + const stream = db.streams.get(id); if (!stream) { + logger.warn("Stream not found for stop action", { correlationId, streamId: id }); return createErrorResponse("STREAM_NOT_FOUND", `Stream '${id}' not found`, 404); } if (stream.status !== "active" && stream.status !== "draft") { + logger.warn("Invalid stream state for stop action", { correlationId, streamId: id, status: stream.status }); return createErrorResponse("INVALID_STREAM_STATE", "Only active or draft streams can be stopped", 409); } stream.status = "ended"; stream.nextAction = "withdraw"; stream.updatedAt = new Date().toISOString(); db.streams.set(id, stream); + + logger.info("Stream stopped successfully", { + correlationId, + streamId: id, + action: "stop", + status: "success", + stream: redact(stream) + }); + return NextResponse.json({ data: stream }); } diff --git a/app/api/streams/[id]/withdraw/route.ts b/app/api/streams/[id]/withdraw/route.ts index c60bade..58ffb15 100644 --- a/app/api/streams/[id]/withdraw/route.ts +++ b/app/api/streams/[id]/withdraw/route.ts @@ -1,5 +1,8 @@ import { NextResponse } from "next/server"; import { db } from "@/app/lib/db"; +import { logger } from "@/app/lib/logger"; +import { getCorrelationContext } from "@/app/lib/correlation-middleware"; +import { redact } from "@/app/lib/privacy"; function createErrorResponse(code: string, message: string, status: number) { return NextResponse.json({ error: { code, message, request_id: "mock-request-id" } }, { status }); @@ -10,16 +13,29 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; + const correlationId = getCorrelationContext()?.correlationId || "unknown"; + const stream = db.streams.get(id); if (!stream) { + logger.warn("Stream not found for withdraw action", { correlationId, streamId: id }); return createErrorResponse("STREAM_NOT_FOUND", `Stream '${id}' not found`, 404); } if (stream.status !== "ended") { + logger.warn("Invalid stream state for withdraw action", { correlationId, streamId: id, status: stream.status }); return createErrorResponse("INVALID_STREAM_STATE", "Only ended streams can be withdrawn from", 409); } stream.status = "withdrawn"; stream.nextAction = undefined; stream.updatedAt = new Date().toISOString(); db.streams.set(id, stream); + + logger.info("Stream withdrawn successfully", { + correlationId, + streamId: id, + action: "withdraw", + status: "success", + stream: redact(stream) + }); + return NextResponse.json({ data: stream }); } diff --git a/app/lib/correlation-middleware.test.ts b/app/lib/correlation-middleware.test.ts new file mode 100644 index 0000000..253d5b4 --- /dev/null +++ b/app/lib/correlation-middleware.test.ts @@ -0,0 +1,15 @@ +import { getCorrelationContext, runWithCorrelation } from "./correlation-middleware"; + +describe("correlation-middleware", () => { + it("propagates correlation context", () => { + runWithCorrelation("test-correlation-id", () => { + const context = getCorrelationContext(); + expect(context?.correlationId).toBe("test-correlation-id"); + }); + }); + + it("returns undefined outside of correlation context", () => { + const context = getCorrelationContext(); + expect(context).toBeUndefined(); + }); +}); diff --git a/app/lib/correlation-middleware.ts b/app/lib/correlation-middleware.ts new file mode 100644 index 0000000..b7e1526 --- /dev/null +++ b/app/lib/correlation-middleware.ts @@ -0,0 +1,11 @@ +import { AsyncLocalStorage } from 'async_hooks'; + +const correlationStorage = new AsyncLocalStorage<{ correlationId: string }>(); + +export function getCorrelationContext() { + return correlationStorage.getStore(); +} + +export function runWithCorrelation(correlationId: string, callback: () => T): T { + return correlationStorage.run({ correlationId }, callback); +} diff --git a/app/lib/logger.test.ts b/app/lib/logger.test.ts new file mode 100644 index 0000000..1fe6fd2 --- /dev/null +++ b/app/lib/logger.test.ts @@ -0,0 +1,12 @@ +import { logger } from "./logger"; + +describe("logger", () => { + it("logs info message", () => { + const spy = jest.spyOn(console, 'log').mockImplementation(); + logger.info("test message", { key: "value" }); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('"level":"info"')); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('"message":"test message"')); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('"key":"value"')); + spy.mockRestore(); + }); +}); diff --git a/app/lib/logger.ts b/app/lib/logger.ts new file mode 100644 index 0000000..9e43823 --- /dev/null +++ b/app/lib/logger.ts @@ -0,0 +1,11 @@ +export const logger = { + info: (message: string, context?: Record) => { + console.log(JSON.stringify({ level: 'info', message, ...context, timestamp: new Date().toISOString() })); + }, + warn: (message: string, context?: Record) => { + console.warn(JSON.stringify({ level: 'warn', message, ...context, timestamp: new Date().toISOString() })); + }, + error: (message: string, context?: Record) => { + console.error(JSON.stringify({ level: 'error', message, ...context, timestamp: new Date().toISOString() })); + }, +}; diff --git a/app/lib/privacy.test.ts b/app/lib/privacy.test.ts new file mode 100644 index 0000000..6db6964 --- /dev/null +++ b/app/lib/privacy.test.ts @@ -0,0 +1,21 @@ +import { redact } from "./privacy"; + +describe("privacy", () => { + it("redacts sensitive keys", () => { + const data = { + publicKey: "G123456789", + signature: "some-sig", + email: "test@example.org", + amount: "100", + nested: { + secret: "top-secret" + } + }; + const redacted = redact(data); + expect(redacted.publicKey).toBe("[REDACTED]"); + expect(redacted.signature).toBe("[REDACTED]"); + expect(redacted.email).toBe("[REDACTED]"); + expect(redacted.amount).toBe("100"); + expect(redacted.nested.secret).toBe("[REDACTED]"); + }); +}); diff --git a/app/lib/privacy.ts b/app/lib/privacy.ts new file mode 100644 index 0000000..8929162 --- /dev/null +++ b/app/lib/privacy.ts @@ -0,0 +1,13 @@ +export function redact(data: any): any { + if (typeof data !== 'object' || data === null) return data; + const redacted = { ...data }; + const keysToRedact = ['signature', 'publicKey', 'secret', 'password', 'token', 'email']; + for (const key in redacted) { + if (keysToRedact.includes(key.toLowerCase())) { + redacted[key] = '[REDACTED]'; + } else if (typeof redacted[key] === 'object') { + redacted[key] = redact(redacted[key]); + } + } + return redacted; +}