feat:add otel logging
This commit is contained in:
72
packages/db/code-migrations/13-add-logs.ts
Normal file
72
packages/db/code-migrations/13-add-logs.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { createTable, runClickhouseMigrationCommands } from '../src/clickhouse/migration';
|
||||
import { getIsCluster, printBoxMessage } from './helpers';
|
||||
|
||||
export async function up() {
|
||||
const replicatedVersion = '1';
|
||||
const isClustered = getIsCluster();
|
||||
|
||||
const sqls: string[] = [];
|
||||
|
||||
sqls.push(
|
||||
...createTable({
|
||||
name: 'logs',
|
||||
columns: [
|
||||
'`id` UUID DEFAULT generateUUIDv4()',
|
||||
'`project_id` String CODEC(ZSTD(3))',
|
||||
'`device_id` String CODEC(ZSTD(3))',
|
||||
'`profile_id` String CODEC(ZSTD(3))',
|
||||
'`session_id` String CODEC(LZ4)',
|
||||
// OpenTelemetry log fields
|
||||
'`timestamp` DateTime64(9) CODEC(DoubleDelta, ZSTD(3))',
|
||||
'`observed_at` DateTime64(9) CODEC(DoubleDelta, ZSTD(3))',
|
||||
'`severity_number` UInt8',
|
||||
'`severity_text` LowCardinality(String)',
|
||||
'`body` String CODEC(ZSTD(3))',
|
||||
'`trace_id` String CODEC(ZSTD(3))',
|
||||
'`span_id` String CODEC(ZSTD(3))',
|
||||
'`trace_flags` UInt32 DEFAULT 0',
|
||||
'`logger_name` LowCardinality(String)',
|
||||
// OTel attributes (log-level key-value pairs)
|
||||
'`attributes` Map(String, String) CODEC(ZSTD(3))',
|
||||
// OTel resource attributes (device/app metadata)
|
||||
'`resource` Map(String, String) CODEC(ZSTD(3))',
|
||||
// Server-enriched context
|
||||
'`sdk_name` LowCardinality(String)',
|
||||
'`sdk_version` LowCardinality(String)',
|
||||
'`country` LowCardinality(FixedString(2))',
|
||||
'`city` String',
|
||||
'`region` LowCardinality(String)',
|
||||
'`os` LowCardinality(String)',
|
||||
'`os_version` LowCardinality(String)',
|
||||
'`browser` LowCardinality(String)',
|
||||
'`browser_version` LowCardinality(String)',
|
||||
'`device` LowCardinality(String)',
|
||||
'`brand` LowCardinality(String)',
|
||||
'`model` LowCardinality(String)',
|
||||
],
|
||||
indices: [
|
||||
'INDEX idx_severity_number severity_number TYPE minmax GRANULARITY 1',
|
||||
'INDEX idx_body body TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 1',
|
||||
'INDEX idx_trace_id trace_id TYPE bloom_filter GRANULARITY 1',
|
||||
'INDEX idx_logger_name logger_name TYPE bloom_filter GRANULARITY 1',
|
||||
],
|
||||
orderBy: ['project_id', 'toDate(timestamp)', 'severity_number', 'device_id'],
|
||||
partitionBy: 'toYYYYMM(timestamp)',
|
||||
settings: {
|
||||
index_granularity: 8192,
|
||||
ttl_only_drop_parts: 1,
|
||||
},
|
||||
distributionHash: 'cityHash64(project_id, toString(toStartOfHour(timestamp)))',
|
||||
replicatedVersion,
|
||||
isClustered,
|
||||
}),
|
||||
);
|
||||
|
||||
printBoxMessage('Running migration: 13-add-logs', [
|
||||
'Creates the logs table for OpenTelemetry-compatible device/app log capture.',
|
||||
]);
|
||||
|
||||
if (!process.argv.includes('--dry')) {
|
||||
await runClickhouseMigrationCommands(sqls);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BotBuffer as BotBufferRedis } from './bot-buffer';
|
||||
import { EventBuffer as EventBufferRedis } from './event-buffer';
|
||||
import { GroupBuffer } from './group-buffer';
|
||||
import { LogBuffer } from './log-buffer';
|
||||
import { ProfileBackfillBuffer } from './profile-backfill-buffer';
|
||||
import { ProfileBuffer as ProfileBufferRedis } from './profile-buffer';
|
||||
import { ReplayBuffer } from './replay-buffer';
|
||||
@@ -13,6 +14,8 @@ export const sessionBuffer = new SessionBuffer();
|
||||
export const profileBackfillBuffer = new ProfileBackfillBuffer();
|
||||
export const replayBuffer = new ReplayBuffer();
|
||||
export const groupBuffer = new GroupBuffer();
|
||||
export const logBuffer = new LogBuffer();
|
||||
|
||||
export type { ProfileBackfillEntry } from './profile-backfill-buffer';
|
||||
export type { IClickhouseSessionReplayChunk } from './replay-buffer';
|
||||
export type { IClickhouseLog } from './log-buffer';
|
||||
|
||||
269
packages/db/src/buffers/log-buffer.ts
Normal file
269
packages/db/src/buffers/log-buffer.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { getSafeJson } from '@openpanel/json';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import { ch } from '../clickhouse/client';
|
||||
import { BaseBuffer } from './base-buffer';
|
||||
|
||||
export interface IClickhouseLog {
|
||||
id?: string;
|
||||
project_id: string;
|
||||
device_id: string;
|
||||
profile_id: string;
|
||||
session_id: string;
|
||||
timestamp: string;
|
||||
observed_at: string;
|
||||
severity_number: number;
|
||||
severity_text: string;
|
||||
body: string;
|
||||
trace_id: string;
|
||||
span_id: string;
|
||||
trace_flags: number;
|
||||
logger_name: string;
|
||||
attributes: Record<string, string>;
|
||||
resource: Record<string, string>;
|
||||
sdk_name: string;
|
||||
sdk_version: string;
|
||||
country: string;
|
||||
city: string;
|
||||
region: string;
|
||||
os: string;
|
||||
os_version: string;
|
||||
browser: string;
|
||||
browser_version: string;
|
||||
device: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export class LogBuffer extends BaseBuffer {
|
||||
private batchSize = process.env.LOG_BUFFER_BATCH_SIZE
|
||||
? Number.parseInt(process.env.LOG_BUFFER_BATCH_SIZE, 10)
|
||||
: 4000;
|
||||
private chunkSize = process.env.LOG_BUFFER_CHUNK_SIZE
|
||||
? Number.parseInt(process.env.LOG_BUFFER_CHUNK_SIZE, 10)
|
||||
: 1000;
|
||||
private microBatchIntervalMs = process.env.LOG_BUFFER_MICRO_BATCH_MS
|
||||
? Number.parseInt(process.env.LOG_BUFFER_MICRO_BATCH_MS, 10)
|
||||
: 10;
|
||||
private microBatchMaxSize = process.env.LOG_BUFFER_MICRO_BATCH_SIZE
|
||||
? Number.parseInt(process.env.LOG_BUFFER_MICRO_BATCH_SIZE, 10)
|
||||
: 100;
|
||||
|
||||
private pendingLogs: IClickhouseLog[] = [];
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private isFlushing = false;
|
||||
private flushRetryCount = 0;
|
||||
|
||||
private queueKey = 'log_buffer:queue';
|
||||
protected bufferCounterKey = 'log_buffer:total_count';
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
name: 'log',
|
||||
onFlush: async () => {
|
||||
await this.processBuffer();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
add(log: IClickhouseLog) {
|
||||
this.pendingLogs.push(log);
|
||||
|
||||
if (this.pendingLogs.length >= this.microBatchMaxSize) {
|
||||
this.flushLocalBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.flushTimer) {
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
this.flushLocalBuffer();
|
||||
}, this.microBatchIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
public async flush() {
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
await this.flushLocalBuffer();
|
||||
}
|
||||
|
||||
private async flushLocalBuffer() {
|
||||
if (this.isFlushing || this.pendingLogs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isFlushing = true;
|
||||
const logsToFlush = this.pendingLogs;
|
||||
this.pendingLogs = [];
|
||||
|
||||
try {
|
||||
// Push to Redis queue for processing
|
||||
const pipeline = getRedisCache().pipeline();
|
||||
for (const log of logsToFlush) {
|
||||
pipeline.lpush(this.queueKey, JSON.stringify(log));
|
||||
}
|
||||
await pipeline.exec();
|
||||
|
||||
// Increment counter
|
||||
await getRedisCache().incrby(this.bufferCounterKey, logsToFlush.length);
|
||||
|
||||
this.flushRetryCount = 0;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to push logs to Redis queue', { error });
|
||||
// Re-queue locally on failure
|
||||
this.pendingLogs = logsToFlush.concat(this.pendingLogs);
|
||||
this.flushRetryCount++;
|
||||
|
||||
// If max retries exceeded, log and drop
|
||||
if (this.flushRetryCount >= 3) {
|
||||
this.logger.error('Max retries exceeded, dropping logs', {
|
||||
droppedCount: this.pendingLogs.length,
|
||||
});
|
||||
this.pendingLogs = [];
|
||||
this.flushRetryCount = 0;
|
||||
}
|
||||
} finally {
|
||||
this.isFlushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async processBuffer() {
|
||||
const startTime = Date.now();
|
||||
const redis = getRedisCache();
|
||||
|
||||
try {
|
||||
// Get batch of logs from Redis
|
||||
const batch: string[] = [];
|
||||
const pipeline = redis.pipeline();
|
||||
|
||||
for (let i = 0; i < this.batchSize; i++) {
|
||||
pipeline.rpop(this.queueKey);
|
||||
}
|
||||
|
||||
const results = await pipeline.exec();
|
||||
if (!results) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
if (result[1]) {
|
||||
batch.push(result[1] as string);
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info(`Processing ${batch.length} logs`);
|
||||
|
||||
// Parse logs
|
||||
const logs: IClickhouseLog[] = [];
|
||||
for (const item of batch) {
|
||||
try {
|
||||
const parsed = getSafeJson<IClickhouseLog>(item);
|
||||
if (parsed) {
|
||||
logs.push(parsed);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to parse log', { error, item });
|
||||
}
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert into ClickHouse in chunks
|
||||
const chunks = this.chunks(logs, this.chunkSize);
|
||||
for (const chunk of chunks) {
|
||||
await this.insertChunk(chunk);
|
||||
}
|
||||
|
||||
// Decrement counter
|
||||
await redis.decrby(this.bufferCounterKey, batch.length);
|
||||
|
||||
this.logger.info('Logs processed successfully', {
|
||||
count: logs.length,
|
||||
elapsed: Date.now() - startTime,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to process logs', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async insertChunk(logs: IClickhouseLog[]) {
|
||||
const query = `
|
||||
INSERT INTO logs (
|
||||
id, project_id, device_id, profile_id, session_id,
|
||||
timestamp, observed_at, severity_number, severity_text, body,
|
||||
trace_id, span_id, trace_flags, logger_name, attributes, resource,
|
||||
sdk_name, sdk_version, country, city, region,
|
||||
os, os_version, browser, browser_version, device, brand, model
|
||||
)
|
||||
VALUES
|
||||
`;
|
||||
|
||||
const values = logs
|
||||
.map((log) => {
|
||||
return `(
|
||||
generateUUIDv4(),
|
||||
${escape(log.project_id)},
|
||||
${escape(log.device_id)},
|
||||
${escape(log.profile_id)},
|
||||
${escape(log.session_id)},
|
||||
${escape(log.timestamp)},
|
||||
${escape(log.observed_at)},
|
||||
${log.severity_number},
|
||||
${escape(log.severity_text)},
|
||||
${escape(log.body)},
|
||||
${escape(log.trace_id)},
|
||||
${escape(log.span_id)},
|
||||
${log.trace_flags},
|
||||
${escape(log.logger_name)},
|
||||
${mapToSql(log.attributes)},
|
||||
${mapToSql(log.resource)},
|
||||
${escape(log.sdk_name)},
|
||||
${escape(log.sdk_version)},
|
||||
${escape(log.country)},
|
||||
${escape(log.city)},
|
||||
${escape(log.region)},
|
||||
${escape(log.os)},
|
||||
${escape(log.os_version)},
|
||||
${escape(log.browser)},
|
||||
${escape(log.browser_version)},
|
||||
${escape(log.device)},
|
||||
${escape(log.brand)},
|
||||
${escape(log.model)}
|
||||
)`;
|
||||
})
|
||||
.join(',');
|
||||
|
||||
await ch.query({
|
||||
query: `${query} ${values}`,
|
||||
clickhouse_settings: {
|
||||
wait_end_of_query: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function escape(value: string): string {
|
||||
if (value === null || value === undefined) {
|
||||
return "''";
|
||||
}
|
||||
return `'${value.replace(/'/g, "\\'").replace(/\\/g, '\\\\')}'`;
|
||||
}
|
||||
|
||||
function mapToSql(map: Record<string, string>): string {
|
||||
if (!map || Object.keys(map).length === 0) {
|
||||
return '{}';
|
||||
}
|
||||
const entries = Object.entries(map)
|
||||
.map(([k, v]) => `${escape(k)}: ${escape(v)}`)
|
||||
.join(', ');
|
||||
return `{${entries}}`;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { createLogger } from '@openpanel/logger';
|
||||
import { getRedisGroupQueue, getRedisQueue } from '@openpanel/redis';
|
||||
import { Queue } from 'bullmq';
|
||||
import { Queue as GroupQueue } from 'groupmq';
|
||||
import type { ITrackPayload } from '../../validation';
|
||||
import type { ILogPayload, ITrackPayload } from '../../validation';
|
||||
|
||||
export const EVENTS_GROUP_QUEUES_SHARDS = Number.parseInt(
|
||||
process.env.EVENTS_GROUP_QUEUES_SHARDS || '1',
|
||||
@@ -297,3 +297,50 @@ export const gscQueue = new Queue<GscQueuePayload>(getQueueName('gsc'), {
|
||||
removeOnFail: 100,
|
||||
},
|
||||
});
|
||||
|
||||
export type LogsQueuePayload = {
|
||||
type: 'incomingLog';
|
||||
payload: {
|
||||
projectId: string;
|
||||
log: ILogPayload & {
|
||||
timestamp: string;
|
||||
};
|
||||
uaInfo:
|
||||
| {
|
||||
readonly isServer: true;
|
||||
readonly device: 'server';
|
||||
readonly os: '';
|
||||
readonly osVersion: '';
|
||||
readonly browser: '';
|
||||
readonly browserVersion: '';
|
||||
readonly brand: '';
|
||||
readonly model: '';
|
||||
}
|
||||
| {
|
||||
readonly os: string | undefined;
|
||||
readonly osVersion: string | undefined;
|
||||
readonly browser: string | undefined;
|
||||
readonly browserVersion: string | undefined;
|
||||
readonly device: string;
|
||||
readonly brand: string | undefined;
|
||||
readonly model: string | undefined;
|
||||
readonly isServer: false;
|
||||
};
|
||||
geo: {
|
||||
country: string | undefined;
|
||||
city: string | undefined;
|
||||
region: string | undefined;
|
||||
};
|
||||
headers: Record<string, string | undefined>;
|
||||
deviceId: string;
|
||||
sessionId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const logsQueue = new Queue<LogsQueuePayload>(getQueueName('logs'), {
|
||||
connection: getRedisQueue(),
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
IGroupPayload as GroupPayload,
|
||||
IIdentifyPayload as IdentifyPayload,
|
||||
IIncrementPayload as IncrementPayload,
|
||||
ILogPayload,
|
||||
ISeverityText,
|
||||
ITrackHandlerPayload as TrackHandlerPayload,
|
||||
ITrackPayload as TrackPayload,
|
||||
} from '@openpanel/validation';
|
||||
@@ -23,6 +25,8 @@ export type {
|
||||
TrackPayload,
|
||||
};
|
||||
|
||||
export type LogProperties = Omit<ILogPayload, 'body' | 'severity'>;
|
||||
|
||||
export interface TrackProperties {
|
||||
[key: string]: unknown;
|
||||
profileId?: string;
|
||||
@@ -48,6 +52,19 @@ export interface OpenPanelOptions {
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
interface LogPayloadForQueue {
|
||||
body: string;
|
||||
severity: ISeverityText;
|
||||
timestamp: string;
|
||||
profileId?: string | number;
|
||||
loggerName?: string;
|
||||
traceId?: string;
|
||||
spanId?: string;
|
||||
traceFlags?: number;
|
||||
attributes?: Record<string, string>;
|
||||
resource?: Record<string, string>;
|
||||
}
|
||||
|
||||
export class OpenPanel {
|
||||
api: Api;
|
||||
options: OpenPanelOptions;
|
||||
@@ -58,6 +75,12 @@ export class OpenPanel {
|
||||
global?: Record<string, unknown>;
|
||||
queue: TrackHandlerPayload[] = [];
|
||||
|
||||
// Log queue for batching
|
||||
private logQueue: LogPayloadForQueue[] = [];
|
||||
private logFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private logFlushIntervalMs = 1000;
|
||||
private logFlushMaxSize = 100;
|
||||
|
||||
constructor(options: OpenPanelOptions) {
|
||||
this.options = options;
|
||||
|
||||
@@ -327,6 +350,67 @@ export class OpenPanel {
|
||||
this.queue = remaining;
|
||||
}
|
||||
|
||||
captureLog(
|
||||
severity: ISeverityText,
|
||||
body: string,
|
||||
properties?: LogProperties,
|
||||
) {
|
||||
if (this.options.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry: LogPayloadForQueue = {
|
||||
body,
|
||||
severity,
|
||||
timestamp: properties?.timestamp ?? new Date().toISOString(),
|
||||
...(this.profileId ? { profileId: this.profileId } : {}),
|
||||
...(properties?.loggerName ? { loggerName: properties.loggerName } : {}),
|
||||
...(properties?.traceId ? { traceId: properties.traceId } : {}),
|
||||
...(properties?.spanId ? { spanId: properties.spanId } : {}),
|
||||
...(properties?.traceFlags !== undefined
|
||||
? { traceFlags: properties.traceFlags }
|
||||
: {}),
|
||||
...(properties?.attributes ? { attributes: properties.attributes } : {}),
|
||||
...(properties?.resource ? { resource: properties.resource } : {}),
|
||||
};
|
||||
|
||||
this.logQueue.push(entry);
|
||||
|
||||
if (this.logQueue.length >= this.logFlushMaxSize) {
|
||||
this.flushLogs();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.logFlushTimer) {
|
||||
this.logFlushTimer = setTimeout(() => {
|
||||
this.logFlushTimer = null;
|
||||
this.flushLogs();
|
||||
}, this.logFlushIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
private async flushLogs() {
|
||||
if (this.logFlushTimer) {
|
||||
clearTimeout(this.logFlushTimer);
|
||||
this.logFlushTimer = null;
|
||||
}
|
||||
|
||||
if (this.logQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = this.logQueue;
|
||||
this.logQueue = [];
|
||||
|
||||
try {
|
||||
await this.api.fetch('/logs', { logs: batch });
|
||||
} catch (error) {
|
||||
this.log('Failed to flush logs', error);
|
||||
// Re-queue on failure
|
||||
this.logQueue = batch.concat(this.logQueue);
|
||||
}
|
||||
}
|
||||
|
||||
log(...args: any[]) {
|
||||
if (this.options.debug) {
|
||||
console.log('[OpenPanel.dev]', ...args);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { getProjectAccess } from './src/access';
|
||||
export * from './src/root';
|
||||
export * from './src/trpc';
|
||||
export type { IServiceLog } from './src/routers/log';
|
||||
|
||||
@@ -10,6 +10,7 @@ import { gscRouter } from './routers/gsc';
|
||||
import { importRouter } from './routers/import';
|
||||
import { insightRouter } from './routers/insight';
|
||||
import { integrationRouter } from './routers/integration';
|
||||
import { logRouter } from './routers/log';
|
||||
import { notificationRouter } from './routers/notification';
|
||||
import { onboardingRouter } from './routers/onboarding';
|
||||
import { organizationRouter } from './routers/organization';
|
||||
@@ -57,6 +58,7 @@ export const appRouter = createTRPCRouter({
|
||||
email: emailRouter,
|
||||
gsc: gscRouter,
|
||||
group: groupRouter,
|
||||
log: logRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
212
packages/trpc/src/routers/log.ts
Normal file
212
packages/trpc/src/routers/log.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { chQuery, convertClickhouseDateToJs } from '@openpanel/db';
|
||||
import { zSeverityText } from '@openpanel/validation';
|
||||
import sqlstring from 'sqlstring';
|
||||
import { z } from 'zod';
|
||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||
|
||||
export interface IServiceLog {
|
||||
id: string;
|
||||
projectId: string;
|
||||
deviceId: string;
|
||||
profileId: string;
|
||||
sessionId: string;
|
||||
timestamp: Date;
|
||||
severityNumber: number;
|
||||
severityText: string;
|
||||
body: string;
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
traceFlags: number;
|
||||
loggerName: string;
|
||||
attributes: Record<string, string>;
|
||||
resource: Record<string, string>;
|
||||
sdkName: string;
|
||||
sdkVersion: string;
|
||||
country: string;
|
||||
city: string;
|
||||
region: string;
|
||||
os: string;
|
||||
osVersion: string;
|
||||
browser: string;
|
||||
browserVersion: string;
|
||||
device: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface IClickhouseLog {
|
||||
id: string;
|
||||
project_id: string;
|
||||
device_id: string;
|
||||
profile_id: string;
|
||||
session_id: string;
|
||||
timestamp: string;
|
||||
severity_number: number;
|
||||
severity_text: string;
|
||||
body: string;
|
||||
trace_id: string;
|
||||
span_id: string;
|
||||
trace_flags: number;
|
||||
logger_name: string;
|
||||
attributes: Record<string, string>;
|
||||
resource: Record<string, string>;
|
||||
sdk_name: string;
|
||||
sdk_version: string;
|
||||
country: string;
|
||||
city: string;
|
||||
region: string;
|
||||
os: string;
|
||||
os_version: string;
|
||||
browser: string;
|
||||
browser_version: string;
|
||||
device: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
function toServiceLog(row: IClickhouseLog): IServiceLog {
|
||||
return {
|
||||
id: row.id,
|
||||
projectId: row.project_id,
|
||||
deviceId: row.device_id,
|
||||
profileId: row.profile_id,
|
||||
sessionId: row.session_id,
|
||||
timestamp: convertClickhouseDateToJs(row.timestamp),
|
||||
severityNumber: row.severity_number,
|
||||
severityText: row.severity_text,
|
||||
body: row.body,
|
||||
traceId: row.trace_id,
|
||||
spanId: row.span_id,
|
||||
traceFlags: row.trace_flags,
|
||||
loggerName: row.logger_name,
|
||||
attributes: row.attributes,
|
||||
resource: row.resource,
|
||||
sdkName: row.sdk_name,
|
||||
sdkVersion: row.sdk_version,
|
||||
country: row.country,
|
||||
city: row.city,
|
||||
region: row.region,
|
||||
os: row.os,
|
||||
osVersion: row.os_version,
|
||||
browser: row.browser,
|
||||
browserVersion: row.browser_version,
|
||||
device: row.device,
|
||||
brand: row.brand,
|
||||
model: row.model,
|
||||
};
|
||||
}
|
||||
|
||||
export const logRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
cursor: z.string().nullish(),
|
||||
severity: z.array(zSeverityText).optional(),
|
||||
search: z.string().optional(),
|
||||
loggerName: z.string().optional(),
|
||||
startDate: z.date().optional(),
|
||||
endDate: z.date().optional(),
|
||||
take: z.number().default(50),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const { projectId, cursor, severity, search, loggerName, startDate, endDate, take } = input;
|
||||
|
||||
const conditions: string[] = [
|
||||
`project_id = ${sqlstring.escape(projectId)}`,
|
||||
];
|
||||
|
||||
if (cursor) {
|
||||
conditions.push(`timestamp < ${sqlstring.escape(cursor)}`);
|
||||
}
|
||||
|
||||
if (severity && severity.length > 0) {
|
||||
const escaped = severity.map((s) => sqlstring.escape(s)).join(', ');
|
||||
conditions.push(`severity_text IN (${escaped})`);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
conditions.push(`body ILIKE ${sqlstring.escape(`%${search}%`)}`);
|
||||
}
|
||||
|
||||
if (loggerName) {
|
||||
conditions.push(`logger_name = ${sqlstring.escape(loggerName)}`);
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
conditions.push(`timestamp >= ${sqlstring.escape(startDate.toISOString())}`);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
conditions.push(`timestamp <= ${sqlstring.escape(endDate.toISOString())}`);
|
||||
}
|
||||
|
||||
const where = conditions.join(' AND ');
|
||||
|
||||
const rows = await chQuery<IClickhouseLog>(
|
||||
`SELECT
|
||||
id, project_id, device_id, profile_id, session_id,
|
||||
timestamp, severity_number, severity_text, body,
|
||||
trace_id, span_id, trace_flags, logger_name,
|
||||
attributes, resource,
|
||||
sdk_name, sdk_version,
|
||||
country, city, region, os, os_version,
|
||||
browser, browser_version, device, brand, model
|
||||
FROM logs
|
||||
WHERE ${where}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ${take + 1}`,
|
||||
);
|
||||
|
||||
const hasMore = rows.length > take;
|
||||
const data = rows.slice(0, take).map(toServiceLog);
|
||||
const lastItem = data[data.length - 1];
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
next: hasMore && lastItem ? lastItem.timestamp.toISOString() : null,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
severityCounts: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
startDate: z.date().optional(),
|
||||
endDate: z.date().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const { projectId, startDate, endDate } = input;
|
||||
|
||||
const conditions: string[] = [
|
||||
`project_id = ${sqlstring.escape(projectId)}`,
|
||||
];
|
||||
|
||||
if (startDate) {
|
||||
conditions.push(`timestamp >= ${sqlstring.escape(startDate.toISOString())}`);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
conditions.push(`timestamp <= ${sqlstring.escape(endDate.toISOString())}`);
|
||||
}
|
||||
|
||||
const where = conditions.join(' AND ');
|
||||
|
||||
const rows = await chQuery<{ severity_text: string; count: number }>(
|
||||
`SELECT severity_text, count() AS count
|
||||
FROM logs
|
||||
WHERE ${where}
|
||||
GROUP BY severity_text
|
||||
ORDER BY count DESC`,
|
||||
);
|
||||
|
||||
return rows.reduce<Record<string, number>>((acc, row) => {
|
||||
acc[row.severity_text] = row.count;
|
||||
return acc;
|
||||
}, {});
|
||||
}),
|
||||
});
|
||||
@@ -625,3 +625,4 @@ export type ICreateImport = z.infer<typeof zCreateImport>;
|
||||
export * from './event-blocklist';
|
||||
export * from './track.validation';
|
||||
export * from './types.insights';
|
||||
export * from './log.validation';
|
||||
|
||||
60
packages/validation/src/log.validation.ts
Normal file
60
packages/validation/src/log.validation.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* OTel severity number mapping (subset):
|
||||
* TRACE=1, DEBUG=5, INFO=9, WARN=13, ERROR=17, FATAL=21
|
||||
*/
|
||||
export const SEVERITY_TEXT_TO_NUMBER: Record<string, number> = {
|
||||
trace: 1,
|
||||
debug: 5,
|
||||
info: 9,
|
||||
warn: 13,
|
||||
warning: 13,
|
||||
error: 17,
|
||||
fatal: 21,
|
||||
critical: 21,
|
||||
};
|
||||
|
||||
export const zSeverityText = z.enum([
|
||||
'trace',
|
||||
'debug',
|
||||
'info',
|
||||
'warn',
|
||||
'warning',
|
||||
'error',
|
||||
'fatal',
|
||||
'critical',
|
||||
]);
|
||||
|
||||
export type ISeverityText = z.infer<typeof zSeverityText>;
|
||||
|
||||
export const zLogPayload = z.object({
|
||||
/** Log message / body */
|
||||
body: z.string().min(1),
|
||||
/** Severity level as text */
|
||||
severity: zSeverityText.default('info'),
|
||||
/** Optional override for the numeric OTel severity (1-24) */
|
||||
severityNumber: z.number().int().min(1).max(24).optional(),
|
||||
/** ISO 8601 timestamp; defaults to server receive time if omitted */
|
||||
timestamp: z.string().datetime({ offset: true }).optional(),
|
||||
/** Logger name (e.g. "com.example.MyActivity") */
|
||||
loggerName: z.string().optional(),
|
||||
/** W3C trace context */
|
||||
traceId: z.string().optional(),
|
||||
spanId: z.string().optional(),
|
||||
traceFlags: z.number().int().min(0).optional(),
|
||||
/** Log-level key-value attributes */
|
||||
attributes: z.record(z.string(), z.string()).optional(),
|
||||
/** Resource/device attributes (app version, runtime, etc.) */
|
||||
resource: z.record(z.string(), z.string()).optional(),
|
||||
/** Profile/user ID to associate with this log */
|
||||
profileId: z.union([z.string().min(1), z.number()]).optional(),
|
||||
});
|
||||
|
||||
export type ILogPayload = z.infer<typeof zLogPayload>;
|
||||
|
||||
export const zLogBatchPayload = z.object({
|
||||
logs: z.array(zLogPayload).min(1).max(500),
|
||||
});
|
||||
|
||||
export type ILogBatchPayload = z.infer<typeof zLogBatchPayload>;
|
||||
Reference in New Issue
Block a user