publish(task 14): structured json logger

createLogger(opts) sammelt postSuccess/postFailed/postSkippedDraft-
events, druckt menschenlesbare zeilen (✓/✗/-), liefert am ende ein
RunLog mit allen einträgen plus start/end-timestamps. writeJson()
schreibt die komplette summary als json für archivierung/ci-artifact.
2 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jörg Lohrer 2026-04-18 05:35:41 +02:00
parent b6196f1052
commit db85061287
2 changed files with 156 additions and 0 deletions

106
publish/src/core/log.ts Normal file
View File

@ -0,0 +1,106 @@
export type RunMode = 'diff' | 'force-all' | 'post-single'
export interface PostLog {
slug: string
status: 'success' | 'failed' | 'skipped-draft'
action?: 'new' | 'update'
event_id?: string
relays_ok?: string[]
relays_failed?: string[]
blossom_servers_ok?: string[]
images_uploaded?: number
duration_ms?: number
error?: string
}
export interface RunLog {
run_id: string
started_at: string
ended_at: string
mode: RunMode
posts: PostLog[]
exit_code: number
}
export interface SuccessArgs {
slug: string
action: 'new' | 'update'
eventId: string
relaysOk: string[]
relaysFailed: string[]
blossomServersOk: string[]
imagesUploaded: number
durationMs: number
}
export interface FailedArgs {
slug: string
error: string
durationMs: number
}
export interface LoggerOptions {
mode: RunMode
runId: string
print?: (line: string) => void
now?: () => Date
}
export interface Logger {
postSuccess(args: SuccessArgs): void
postFailed(args: FailedArgs): void
postSkippedDraft(slug: string): void
finalize(exitCode: number): RunLog
writeJson(path: string, summary: RunLog): Promise<void>
}
export function createLogger(opts: LoggerOptions): Logger {
const print = opts.print ?? ((line: string) => console.log(line))
const now = opts.now ?? (() => new Date())
const posts: PostLog[] = []
const startedAt = now().toISOString()
return {
postSuccess(a) {
posts.push({
slug: a.slug,
status: 'success',
action: a.action,
event_id: a.eventId,
relays_ok: a.relaysOk,
relays_failed: a.relaysFailed,
blossom_servers_ok: a.blossomServersOk,
images_uploaded: a.imagesUploaded,
duration_ms: a.durationMs,
})
print(
`${a.slug} (${a.action}) — relays:${a.relaysOk.length}ok/${a.relaysFailed.length}fail — ${a.durationMs}ms`,
)
},
postFailed(a) {
posts.push({
slug: a.slug,
status: 'failed',
error: a.error,
duration_ms: a.durationMs,
})
print(`${a.slug}${a.error}`)
},
postSkippedDraft(slug) {
posts.push({ slug, status: 'skipped-draft' })
print(`- ${slug} (draft, skipped)`)
},
finalize(exitCode) {
return {
run_id: opts.runId,
started_at: startedAt,
ended_at: now().toISOString(),
mode: opts.mode,
posts,
exit_code: exitCode,
}
},
writeJson(path, summary) {
return Deno.writeTextFile(path, JSON.stringify(summary, null, 2))
},
}
}

50
publish/tests/log_test.ts Normal file
View File

@ -0,0 +1,50 @@
import { assertEquals } from '@std/assert'
import { createLogger } from '../src/core/log.ts'
Deno.test('logger: sammelt post-einträge und schreibt summary', () => {
const sink: string[] = []
const logger = createLogger({
mode: 'force-all',
runId: 'run-1',
print: (line) => sink.push(line),
now: () => new Date('2026-04-16T10:00:00Z'),
})
logger.postSuccess({
slug: 's1',
action: 'new',
eventId: 'ev1',
relaysOk: ['wss://r1'],
relaysFailed: [],
blossomServersOk: [],
imagesUploaded: 0,
durationMs: 10,
})
logger.postSkippedDraft('s2')
const summary = logger.finalize(0)
assertEquals(summary.run_id, 'run-1')
assertEquals(summary.mode, 'force-all')
assertEquals(summary.posts.length, 2)
assertEquals(summary.posts[0].status, 'success')
assertEquals(summary.posts[1].status, 'skipped-draft')
assertEquals(summary.exit_code, 0)
assertEquals(sink.some((s) => s.includes('s1')), true)
})
Deno.test('logger: writeJson schreibt datei', async () => {
const tmp = await Deno.makeTempDir()
try {
const logger = createLogger({
mode: 'diff',
runId: 'run-2',
print: () => {},
now: () => new Date('2026-04-16T10:00:00Z'),
})
const summary = logger.finalize(0)
await logger.writeJson(`${tmp}/out.json`, summary)
const text = await Deno.readTextFile(`${tmp}/out.json`)
const parsed = JSON.parse(text)
assertEquals(parsed.run_id, 'run-2')
} finally {
await Deno.remove(tmp, { recursive: true })
}
})