2a9fef2341
## 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
196 lines
5.2 KiB
TypeScript
196 lines
5.2 KiB
TypeScript
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>;
|
|
startFromWorkspaceId?: string;
|
|
workspaceCountLimit?: number;
|
|
dryRun?: boolean;
|
|
verbose?: boolean;
|
|
};
|
|
|
|
export type ParsedUpgradeCommandOptions = {
|
|
workspaceIds?: string[];
|
|
startFromWorkspaceId?: string;
|
|
workspaceCountLimit?: number;
|
|
dryRun?: boolean;
|
|
verbose?: boolean;
|
|
};
|
|
|
|
@Command({
|
|
name: 'upgrade',
|
|
description: 'Upgrade workspaces to the latest version',
|
|
})
|
|
export class UpgradeCommand extends CommandRunner {
|
|
protected logger: CommandLogger;
|
|
|
|
constructor(
|
|
protected readonly upgradeSequenceReaderService: UpgradeSequenceReaderService,
|
|
protected readonly upgradeSequenceRunnerService: UpgradeSequenceRunnerService,
|
|
) {
|
|
super();
|
|
this.logger = new CommandLogger({
|
|
verbose: false,
|
|
constructorName: this.constructor.name,
|
|
});
|
|
}
|
|
|
|
@Option({
|
|
flags: '-d, --dry-run',
|
|
description: 'Simulate the command without making actual changes',
|
|
required: false,
|
|
})
|
|
parseDryRun(): boolean {
|
|
return true;
|
|
}
|
|
|
|
@Option({
|
|
flags: '-v, --verbose',
|
|
description: 'Verbose output',
|
|
required: false,
|
|
})
|
|
parseVerbose(): boolean {
|
|
return true;
|
|
}
|
|
|
|
@Option({
|
|
flags: '-w, --workspace-id [workspace_id]',
|
|
description:
|
|
'workspace id. Command runs on all active/suspended workspaces if not provided.',
|
|
required: false,
|
|
})
|
|
parseWorkspaceId(val: string, previous?: Set<string>): Set<string> {
|
|
const accumulator = previous ?? new Set<string>();
|
|
|
|
accumulator.add(val);
|
|
|
|
return accumulator;
|
|
}
|
|
|
|
@Option({
|
|
flags: '--start-from-workspace-id [workspace_id]',
|
|
description:
|
|
'Start from a specific workspace id. Workspaces are processed in ascending order of id.',
|
|
required: false,
|
|
})
|
|
parseStartFromWorkspaceId(val: string): string {
|
|
return val;
|
|
}
|
|
|
|
@Option({
|
|
flags: '--workspace-count-limit [count]',
|
|
description:
|
|
'Limit the number of workspaces to process. Workspaces are processed in ascending order of id.',
|
|
required: false,
|
|
})
|
|
parseWorkspaceCountLimit(val: string): number {
|
|
const limit = parseInt(val);
|
|
|
|
if (isNaN(limit)) {
|
|
throw new Error('Workspace count limit must be a number');
|
|
}
|
|
|
|
if (limit <= 0) {
|
|
throw new Error('Workspace count limit must be greater than 0');
|
|
}
|
|
|
|
return limit;
|
|
}
|
|
|
|
override async run(
|
|
_passedParams: string[],
|
|
options: RawUpgradeCommandOptions,
|
|
): Promise<void> {
|
|
if (options.verbose) {
|
|
this.logger = new CommandLogger({
|
|
verbose: true,
|
|
constructorName: this.constructor.name,
|
|
});
|
|
}
|
|
|
|
if (
|
|
isDefined(options.workspaceId) &&
|
|
isDefined(options.startFromWorkspaceId)
|
|
) {
|
|
throw new Error(
|
|
'Cannot use --start-from-workspace-id together with -w/--workspace-id',
|
|
);
|
|
}
|
|
|
|
try {
|
|
const sequence = this.upgradeSequenceReaderService.getUpgradeSequence();
|
|
|
|
this.logger.log(
|
|
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,
|
|
options: {
|
|
...options,
|
|
workspaceIds: isDefined(options.workspaceId)
|
|
? Array.from(options.workspaceId)
|
|
: undefined,
|
|
},
|
|
});
|
|
|
|
this.logger.log(
|
|
formatUpgradeLog({
|
|
humanMessage: `Upgrade summary: ${totalSuccesses} workspace(s) succeeded, ${totalFailures} workspace(s) failed`,
|
|
event: 'summary',
|
|
logFields: {
|
|
totalSuccesses,
|
|
totalFailures,
|
|
dryRun: options.dryRun ?? false,
|
|
},
|
|
}),
|
|
);
|
|
|
|
if (totalFailures > 0) {
|
|
throw new Error(
|
|
`Upgrade completed with ${totalFailures} workspace failure(s)`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error);
|
|
|
|
this.logger.error(
|
|
formatUpgradeLog({
|
|
humanMessage: `Upgrade failed: ${errorMessage}`,
|
|
event: 'aborted',
|
|
}),
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|