Skip to content

Commit 35263ff

Browse files
feat(startup): replace the banner with one record when LOG_FORMAT=json
A pipeline that insists every line parses cannot have a logo, and it cannot have it silently: `LOG_FORMAT=json` now writes one `relay starting` record carrying the banner's facts as fields (`version`, `supportedNips`, `payments`, `eventStore`, `clientWorkers`, `maintenanceWorkers`, `relayMonitor`, `dvmWorkers`), with pino's own `level`/`time`/`pid`/`hostname`, and suppresses the centred lines that follow. Anything else — unset, `pretty`, a typo — keeps the banner, because the person reading `docker compose logs` is the default audience. The record is written before any worker forks, so it opens the stream, and it shares the banner's writer, so a test can read stdout instead of reaching into pino.
1 parent 67658a7 commit 35263ff

6 files changed

Lines changed: 104 additions & 6 deletions

File tree

‎.changeset/startup-banner-stdout.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
'nostream': patch
33
---
44

5-
fix(startup): print the banner as text instead of one JSON record per line
5+
fix(startup): print the banner as text, and let `LOG_FORMAT=json` opt out of it
66

7-
`docker compose logs nostream` showed the logo as a single log record with the newlines escaped. The banner and its centred facts now go to stdout as text. Nothing is lost for a log pipeline: `createLogger` stringifies an object argument anyway, so every other line is text too — this one is simply printed where a person can read it.
7+
`docker compose logs nostream` showed the logo as a single log record with the newlines escaped. The banner and its centred facts now go to stdout as text.
8+
9+
A pipeline that requires every line to parse sets `LOG_FORMAT=json`: the logo is then replaced by one `relay starting` record carrying the same facts as fields (`version`, `supportedNips`, `payments`, `eventStore`, `clientWorkers`, …). Any other value keeps the banner, which is what a person reading the logs wants.

‎CONFIGURATION.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The following environment variables can be set:
1010
| RELAY_PORT | Relay's server port | 8008 |
1111
| RELAY_PRIVATE_KEY | Relay's private key in hex | (auto-generated) |
1212
| WORKER_COUNT | Number of workers override | No. of available CPUs |
13+
| LOG_FORMAT | Set to `json` to replace the startup banner with one `relay starting` record, so every line the relay writes parses as JSON. | (banner) |
1314
| DB_URI | PostgreSQL URI (overrides DB_HOST, DB_PORT, etc.) | |
1415
| DB_HOST | PostgresSQL Hostname | |
1516
| DB_PORT | PostgreSQL Port | 5432 |

‎src/app/app.ts‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Cluster, Worker } from 'cluster'
22
import { cpus, hostname } from 'os'
3+
import { levels } from 'pino'
34
import { path, pathEq } from 'ramda'
45
import { FSWatcher } from 'fs'
56

@@ -11,7 +12,7 @@ import packageJson from '../../package.json'
1112
import { Serializable } from 'child_process'
1213
import { Settings } from '../@types/settings'
1314
import { SettingsStatic } from '../utils/settings'
14-
import { BANNER_WIDTH, centre, RELAY_BANNER_LINES } from '../utils/banner'
15+
import { BANNER_WIDTH, centre, isStructuredLogFormat, RELAY_BANNER_LINES } from '../utils/banner'
1516
import { shutdownMetricsTelemetry } from '../telemetry/metrics'
1617
import { OperatorNotificationEventType } from '../@types/operator-notifications'
1718
import { RedisRelayBroadcastFanout } from '../relay-broadcast/redis-relay-broadcast-fanout'
@@ -56,9 +57,14 @@ export class App implements IRunnable {
5657
this.watchers = SettingsStatic.watchSettings()
5758
// Plain text on stdout, not through the logger: in JSON output every line of
5859
// the logo arrived as its own record with the newlines escaped, which is not
59-
// a logo. Nothing is lost for a log pipeline — `createLogger` stringifies an
60-
// object argument anyway, so every other line in this file is text too.
60+
// a logo. A pipeline that insists every line parse sets LOG_FORMAT=json and
61+
// gets one record instead of artwork (see below).
62+
const structuredLogs = isStructuredLogFormat(this.process.env.LOG_FORMAT)
6163
const logLine = (text: string) => {
64+
if (structuredLogs) {
65+
return
66+
}
67+
6268
this.process.stdout.write(`${text}\n`)
6369
}
6470

@@ -102,6 +108,29 @@ export class App implements IRunnable {
102108
? Number(process.env.WORKER_COUNT)
103109
: this.settings().workers?.count || cpus().length
104110

111+
// The structured form of the banner: the same facts as fields, written once
112+
// and before any worker forks, so it opens the stream. The keys are pino's
113+
// own so a pipeline reads this line like every other one.
114+
if (structuredLogs) {
115+
const dvmWorkerCount = Array.isArray(settings?.dvm?.workers) ? settings.dvm.workers.length : 0
116+
this.process.stdout.write(`${JSON.stringify({
117+
level: levels.values.info,
118+
time: Date.now(),
119+
pid: process.pid,
120+
hostname: hostname(),
121+
msg: 'relay starting',
122+
version: packageJson.version,
123+
supportedNips: packageJson.supportedNips,
124+
payments: paymentsEnabled ? 'enabled' : 'disabled',
125+
paymentsProvider: paymentsEnabled ? path(['payments', 'processor'], settings) : undefined,
126+
eventStore: eventStoreBackend,
127+
clientWorkers: workerCount,
128+
maintenanceWorkers: 1,
129+
relayMonitor: settings.nip66?.enabled === true,
130+
dvmWorkers: dvmWorkerCount,
131+
})}\n`)
132+
}
133+
105134
const createWorker = (env: Record<string, string>) => {
106135
const workerEnv = { ...env, [EVENT_STORE_BACKEND_ENV]: eventStoreBackend }
107136
const worker = this.cluster.fork(workerEnv)

‎src/utils/banner.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,12 @@ export const RELAY_BANNER_LINES: readonly string[] = [
2525
*/
2626
export const centre = (line: string, width: number = BANNER_WIDTH): string =>
2727
' '.repeat(Math.max(0, (width - line.length) >> 1)) + line
28+
29+
/**
30+
* `LOG_FORMAT=json` promises a stream in which every line parses as JSON. The
31+
* banner cannot keep that promise, so it is replaced by one record carrying the
32+
* same facts. Anything else — unset, `pretty`, a typo — keeps the banner: the
33+
* person reading `docker compose logs` is the default audience.
34+
*/
35+
export const isStructuredLogFormat = (format: string | undefined): boolean =>
36+
(format ?? '').trim().toLowerCase() === 'json'

‎test/unit/app/app.spec.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,49 @@ describe('App', () => {
175175
expect(written).not.to.include('{"level"')
176176
})
177177

178+
// LOG_FORMAT=json promises a stream in which every line parses. The logo
179+
// cannot keep that promise, so it becomes one record with the same facts.
180+
it('writes one structured record instead of the banner when LOG_FORMAT=json', () => {
181+
sandbox.stub(SettingsStatic, 'watchSettings').returns([])
182+
sandbox.stub(operatorNotificationEnqueue, 'enqueueOperatorNotification').resolves()
183+
sandbox.stub(torClient, 'addOnion').rejects(new Error('tor is not configured'))
184+
let pid = 2001
185+
cluster.fork.callsFake(() => createWorker(String(pid), pid++))
186+
fakeProcess.env = { LOG_FORMAT: 'json' }
187+
const app = new App(
188+
fakeProcess as any,
189+
cluster as any,
190+
sandbox.stub().returns({
191+
payments: { enabled: false },
192+
workers: { count: 1 },
193+
eventStore: { backend: 'postgres' },
194+
} as Settings),
195+
)
196+
197+
app.run()
198+
199+
const lines = fakeProcess.stdout.write.getCalls()
200+
.map((call) => String(call.args[0]).trim())
201+
.filter(Boolean)
202+
203+
expect(lines).to.have.lengthOf(1)
204+
205+
const record = JSON.parse(lines[0])
206+
207+
expect(record).to.include({
208+
level: 30,
209+
msg: 'relay starting',
210+
payments: 'disabled',
211+
eventStore: 'postgres',
212+
clientWorkers: 1,
213+
maintenanceWorkers: 1,
214+
relayMonitor: false,
215+
dvmWorkers: 0,
216+
})
217+
expect(record.version).to.match(/^\d+\.\d+\.\d+/)
218+
expect(record.time).to.be.a('number')
219+
})
220+
178221
it('hands every worker, re-forked ones included, the event store resolved at startup', async () => {
179222
// run() reads both from process.env
180223
const env = { ...process.env }

‎test/unit/utils/banner.spec.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect } from 'chai'
22

3-
import { BANNER_WIDTH, centre, RELAY_BANNER_LINES } from '../../../src/utils/banner'
3+
import { BANNER_WIDTH, centre, isStructuredLogFormat, RELAY_BANNER_LINES } from '../../../src/utils/banner'
44

55
describe('startup banner', () => {
66
// The banner is printed to stdout as text. A line wider than the block would
@@ -31,4 +31,18 @@ describe('startup banner', () => {
3131

3232
expect(centre(exact)).to.equal(exact)
3333
})
34+
35+
// LOG_FORMAT=json is a promise made to a log pipeline, so it is read strictly:
36+
// an unrecognised value falls back to the banner a person can read.
37+
it('reads LOG_FORMAT=json, trimmed and case-insensitively', () => {
38+
expect(isStructuredLogFormat('json')).to.equal(true)
39+
expect(isStructuredLogFormat(' JSON ')).to.equal(true)
40+
})
41+
42+
it('keeps the banner for anything else', () => {
43+
expect(isStructuredLogFormat(undefined)).to.equal(false)
44+
expect(isStructuredLogFormat('')).to.equal(false)
45+
expect(isStructuredLogFormat('pretty')).to.equal(false)
46+
expect(isStructuredLogFormat('jsonlines')).to.equal(false)
47+
})
3448
})

0 commit comments

Comments
 (0)