feat(upgrade): emit structured logfmt logs for upgrade flow (#20539)
## Summary
Adds a small helper that lets every log line in the upgrade flow stay
human-readable while emitting a structured tail that Loki / the
upgrade-status Grafana dashboard can filter on.
Output shape per `logger.log()` call:
```
<humanMessage as-is, may span multiple lines>
[upgrade] event=<event> key=value … ← always single line
```
Same call produces **one** structured Loki event regardless of how
chatty the human-readable part gets — the dashboard's `|= "[upgrade]"`
filter only matches the trailing line.
## Helper API
```ts
formatUpgradeLog({
humanMessage: string, // free-form, multi-line OK, for engineers scrolling raw pod logs
event: string, // required anchor for Loki filtering / dashboards
logFields?: Record<string, // short structured key=value tail
string | number | boolean | null | undefined
>,
});
```
- `humanMessage` is preserved as-is. A thrown `new Error('line one\nline
two')` surfacing through `humanMessage` stays human-readable across
multiple lines.
- `logFields` values are logfmt-escaped: whitespace / `=` / `"` get
quoted, embedded `\` / `"` / `\n` / `\r` / `\t` are escaped, `null` /
`undefined` emit literally (`key=null`, `key=undefined`) instead of
being silently dropped — caught via `isDefined` from
`twenty-shared/utils`.
- `event` itself runs through the same escape so an event name with
whitespace or `=` can't break parsing.
## Example output
```
Initialized upgrade sequence: 8 step(s)
[upgrade] event=sequence.initialized stepCount=8 dryRun=false
Upgrading workspace abc-123 1/10
[upgrade] event=workspace.start workspaceId=abc-123 index=1 total=10 dryRun=false
Upgrade for workspace abc-123 completed.
[upgrade] event=workspace.success workspaceId=abc-123 executedByVersion=1.4.0 dryRun=false
Upgrade summary: 42 workspace(s) succeeded, 1 workspace(s) failed
[upgrade] event=summary totalSuccesses=42 totalFailures=1 dryRun=false
Upgrade failed: Workspace migration runner failed:
- Option id is required
- Option id is invalid
[upgrade] event=aborted totalSuccesses=41 totalFailures=2 dryRun=false
```
Loki query for the dashboard: `{namespace="twenty"} |= "[upgrade]" |
logfmt event, workspaceId, command, executedByVersion`
## Scope
Only the **upgrade-specific** call sites carry the tag:
- `upgrade.command.ts` — `sequence.initialized`, `sequence.step`
(verbose), `summary`, `aborted`
- `upgrade-sequence-runner.service.ts` — `sequence.stopped`,
`sequence.aborted`
- `workspace-command-runner.service.ts` — `workspace.start`,
`workspace.success`, `cache.invalidate.failed`
`instance-command-runner.service.ts` is intentionally **not** tagged —
`runFastInstanceCommand` / `runSlowInstanceCommand` are also invoked
from `RunInstanceCommandsCommand` (DB init / `run-instance-commands`),
so an `[upgrade]` tag there would mislead at init time. Those lines stay
plain-text; stacks still flow on their own via NestJS
`logger.error(message, error.stack)`.
`chalk` is dropped from `upgrade.command.ts` — ANSI escapes break log
parsers and chalk is a no-op without a TTY anyway.
## Tests
9 inline-snapshot tests in `format-upgrade-log.util.spec.ts` surface the
actual output of every interesting shape (summary call site, multi-line
humanMessage, quoted / escaped / control-character logField values,
null/undefined fields, event name escaping). Snapshots double as
documentation of what a real upgrade log line looks like.
## Test plan
- [x] Unit tests green (`jest format-upgrade-log`)
- [x] oxlint + prettier clean
- [x] tsgo typecheck clean on the upgrade module
- [x] CI green
- [ ] Smoke test on staging: run `upgrade` command, confirm `[upgrade]`
structured lines surface in Loki and `| logfmt` extracts fields
This commit is contained in:
+42
-15
@@ -1,10 +1,10 @@
|
||||
import chalk from 'chalk';
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { CommandLogger } from 'src/database/commands/logger';
|
||||
import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
|
||||
import { UpgradeSequenceRunnerService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-runner.service';
|
||||
import { formatUpgradeLog } from 'src/engine/core-modules/upgrade/utils/format-upgrade-log.util';
|
||||
|
||||
type RawUpgradeCommandOptions = {
|
||||
workspaceId?: Set<string>;
|
||||
@@ -126,18 +126,31 @@ export class UpgradeCommand extends CommandRunner {
|
||||
const sequence = this.upgradeSequenceReaderService.getUpgradeSequence();
|
||||
|
||||
this.logger.log(
|
||||
chalk.blue(
|
||||
[
|
||||
'Initialized upgrade sequence:',
|
||||
`- ${sequence.length} step(s)`,
|
||||
...sequence.map(
|
||||
(step, index) =>
|
||||
` [${index}] ${step.kind} — ${step.name} (${step.version})`,
|
||||
),
|
||||
].join('\n '),
|
||||
),
|
||||
formatUpgradeLog({
|
||||
humanMessage: `Initialized upgrade sequence: ${sequence.length} step(s)`,
|
||||
event: 'sequence.initialized',
|
||||
logFields: {
|
||||
stepCount: sequence.length,
|
||||
dryRun: options.dryRun ?? false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [index, step] of sequence.entries()) {
|
||||
this.logger.verbose(
|
||||
formatUpgradeLog({
|
||||
humanMessage: ` [${index}] ${step.kind} — ${step.name} (${step.version})`,
|
||||
event: 'sequence.step',
|
||||
logFields: {
|
||||
index,
|
||||
kind: step.kind,
|
||||
name: step.name,
|
||||
version: step.version,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const { totalSuccesses, totalFailures } =
|
||||
await this.upgradeSequenceRunnerService.run({
|
||||
sequence,
|
||||
@@ -150,9 +163,15 @@ export class UpgradeCommand extends CommandRunner {
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
chalk.blue(
|
||||
`Upgrade summary: ${totalSuccesses} workspace(s) succeeded, ${totalFailures} workspace(s) failed`,
|
||||
),
|
||||
formatUpgradeLog({
|
||||
humanMessage: `Upgrade summary: ${totalSuccesses} workspace(s) succeeded, ${totalFailures} workspace(s) failed`,
|
||||
event: 'summary',
|
||||
logFields: {
|
||||
totalSuccesses,
|
||||
totalFailures,
|
||||
dryRun: options.dryRun ?? false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (totalFailures > 0) {
|
||||
@@ -161,7 +180,15 @@ export class UpgradeCommand extends CommandRunner {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(chalk.red(`Upgrade failed: ${error.message}`));
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
this.logger.error(
|
||||
formatUpgradeLog({
|
||||
humanMessage: `Upgrade failed: ${errorMessage}`,
|
||||
event: 'aborted',
|
||||
}),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user