Add application-logs module with driver pattern for logic function log persistence (#19486)

## Summary

- Introduces a new `application-logs` core module with a driver pattern
(disabled/console/clickhouse) to capture and persist logic function
execution logs
- Adds a ClickHouse `applicationLog` table with per-line log storage,
30-day TTL, and `ORDER BY (workspaceId, timestamp, applicationId,
logicFunctionId)`
- Surfaces application logs in the existing frontend audit logs table as
a new "Application Logs" source with dedicated columns (Function,
Timestamp, Level, Message, Execution ID)

## Details

**Write path**: `LogicFunctionExecutorService.handleExecutionResult()`
parses the multi-line log string from driver output into individual `{
timestamp, level, message }` entries, generates an execution UUID, and
passes them to `ApplicationLogsService.writeLogs()` which delegates to
the configured driver.

**Driver pattern**: Follows the exception-handler module style (Symbol
injection token + `forRootAsync` dynamic module). Three drivers:
- `DISABLED` (default) — no-op, prevents information leaking
- `CONSOLE` — structured stdout logging with level-based `console.*`
calls
- `CLICKHOUSE` — inserts rows into the `applicationLog` ClickHouse table

**Read path**: Extends the existing event-logs module by adding
`APPLICATION_LOG` to the `EventLogTable` enum, table name mapping, and
normalization logic.

**Config**: New `APPLICATION_LOG_DRIVER_TYPE` environment variable
(default: `DISABLED`).
This commit is contained in:
Charles Bochet
2026-04-09 16:35:24 +02:00
committed by GitHub
parent 5116002ca2
commit 36fbfca069
33 changed files with 730 additions and 137 deletions
@@ -12,6 +12,7 @@ const CLICKHOUSE_TABLE_NAMES: Record<EventLogTable, string> = {
[EventLogTable.PAGEVIEW]: 'pageview',
[EventLogTable.OBJECT_EVENT]: 'objectEvent',
[EventLogTable.USAGE_EVENT]: 'usageEvent',
[EventLogTable.APPLICATION_LOG]: 'applicationLog',
};
export type EventLogCleanupParams = {
@@ -49,6 +49,17 @@ type ClickHouseUsageEventRecord = {
metadata?: Record<string, unknown>;
};
type ClickHouseApplicationLogRecord = {
timestamp: string;
applicationId?: string;
logicFunctionId?: string;
logicFunctionName?: string;
executionId?: string;
level?: string;
message?: string;
properties?: Record<string, unknown>;
};
const ALLOWED_TABLES = Object.values(EventLogTable);
const MAX_LIMIT = 10000;
@@ -57,6 +68,7 @@ const CLICKHOUSE_TABLE_NAMES: Record<EventLogTable, string> = {
[EventLogTable.PAGEVIEW]: 'pageview',
[EventLogTable.OBJECT_EVENT]: 'objectEvent',
[EventLogTable.USAGE_EVENT]: 'usageEvent',
[EventLogTable.APPLICATION_LOG]: 'applicationLog',
};
@Injectable()
@@ -85,7 +97,9 @@ export class EventLogsService {
? 'resourceType'
: input.table === EventLogTable.PAGEVIEW
? 'name'
: 'event';
: input.table === EventLogTable.APPLICATION_LOG
? 'logicFunctionName'
: 'event';
const whereClauses: string[] = ['"workspaceId" = {workspaceId:String}'];
const params: Record<string, unknown> = { workspaceId };
@@ -201,7 +215,9 @@ export class EventLogsService {
// userWorkspaceId directly which is more relevant in a workspace context.
// Consider migrating all event tables to userWorkspaceId for consistency.
if (isDefined(filters.userWorkspaceId)) {
if (table === EventLogTable.USAGE_EVENT) {
if (table === EventLogTable.APPLICATION_LOG) {
// Application logs don't have a user column
} else if (table === EventLogTable.USAGE_EVENT) {
whereClauses.push('"userWorkspaceId" = {userWorkspaceId:String}');
params.userWorkspaceId = filters.userWorkspaceId;
} else {
@@ -249,7 +265,10 @@ export class EventLogsService {
}
private normalizeRecords(
records: ClickHouseEventRecord[] | ClickHouseUsageEventRecord[],
records:
| ClickHouseEventRecord[]
| ClickHouseUsageEventRecord[]
| ClickHouseApplicationLogRecord[],
table: EventLogTable,
): EventLogRecord[] {
if (table === EventLogTable.USAGE_EVENT) {
@@ -269,6 +288,21 @@ export class EventLogsService {
}));
}
if (table === EventLogTable.APPLICATION_LOG) {
return (records as ClickHouseApplicationLogRecord[]).map((record) => ({
event: record.logicFunctionName ?? '',
timestamp: new Date(record.timestamp),
properties: {
level: record.level,
message: record.message,
executionId: record.executionId,
logicFunctionId: record.logicFunctionId,
applicationId: record.applicationId,
...(record.properties ?? {}),
},
}));
}
return (records as ClickHouseEventRecord[]).map((record) => {
const eventName =
table === EventLogTable.PAGEVIEW