Skip to content

Commit 5fdeeea

Browse files
authored
feat: adds client/server side logging (#716)
* chore: adds basic logger Signed-off-by: Anthony D. Mays <[email protected]> * chore: configures batching for client logger * wip: attempts to add a prisma transport for logger * chore: updates logs table to use jsonb * chore: adds event beaconing for features * chore: adds environment to logger config * chore: removes file logger from default config --------- Signed-off-by: Anthony D. Mays <[email protected]>
1 parent 2318a98 commit 5fdeeea

File tree

12 files changed

+514
-16
lines changed

12 files changed

+514
-16
lines changed

lib/javascript/fullstack_demo/package-lock.json

Lines changed: 264 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/javascript/fullstack_demo/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
"@upstash/redis": "^1.34.3",
2020
"lowdb": "^7.0.1",
2121
"lucide-react": "^0.468.0",
22-
"next": "^14.2.22"
22+
"next": "^14.2.22",
23+
"uuid": "^11.0.5",
24+
"winston": "^3.17.0"
2325
},
2426
"devDependencies": {
2527
"@testing-library/dom": "^10.4.0",
@@ -46,4 +48,4 @@
4648
"vitest": "^2.1.8",
4749
"vitest-fetch-mock": "^0.4.3"
4850
}
49-
}
51+
}

lib/javascript/fullstack_demo/prisma/schema.prisma

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,12 @@ model Todos {
1818
created_at DateTime @default(now()) @db.Timestamptz(6)
1919
updated_at DateTime @default(now()) @db.Timestamp(6)
2020
}
21+
22+
/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info.
23+
model Logs {
24+
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
25+
level String @db.VarChar
26+
message String @default("") @db.VarChar
27+
timestamp DateTime @db.Timestamp(6)
28+
meta Json?
29+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { logger } from '@/util/server-logger';
2+
3+
export async function POST(request: Request) {
4+
const batch = await request.json();
5+
for (const log of batch) {
6+
const { level, message, vars } = log;
7+
logger.log(level, message, { ...(vars || {}) });
8+
}
9+
return new Response(null, { status: 204 });
10+
}

lib/javascript/fullstack_demo/src/components/add-todo/add-todo.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Todo } from '@/models';
1+
import { LogEvent, Todo } from '@/models';
2+
import { logger } from '@/util/client-logger';
23
import { PlusIcon } from 'lucide-react';
34
import { useState } from 'react';
45

@@ -14,6 +15,7 @@ export const AddTodo: React.FC<AddTodoProps> = ({ onAdd }) => {
1415
if (newTodo.trim() !== '') {
1516
const todo: Todo = { id: Date.now(), text: newTodo, completed: false };
1617
await fetch('/api/todos', { method: 'POST', body: JSON.stringify(todo) });
18+
logger.event(LogEvent.TODO_ADD, { id: todo.id });
1719
setNewTodo('');
1820
if (onAdd) {
1921
onAdd(todo);

lib/javascript/fullstack_demo/src/components/todo-list/todo-list.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Todo } from '@/models';
1+
import { LogEvent, Todo } from '@/models';
2+
import { logger } from '@/util/client-logger';
23
import { TodoComponent } from './todo';
34

45
export type TodoProps = {
@@ -8,22 +9,26 @@ export type TodoProps = {
89

910
export const TodoList: React.FC<TodoProps> = ({ todos, onChange }) => {
1011
const toggleTodo = async (id: number) => {
12+
logger.event(LogEvent.TODO_TOGGLE, { id });
1113
const todo = todos.find((todo) => todo.id === id);
1214
if (!todo) return;
1315
todo.completed = !todo.completed;
1416
await fetch(`/api/todos/${id}`, {
1517
method: 'PATCH',
1618
body: JSON.stringify(todo),
1719
});
20+
logger.event(LogEvent.TODO_TOGGLE, { id });
1821
if (onChange) {
1922
onChange(todo.id);
2023
}
2124
};
2225

2326
const deleteTodo = async (id: number) => {
27+
logger.event(LogEvent.TODO_DELETE, { id });
2428
await fetch(`/api/todos/${id}`, {
2529
method: 'DELETE',
2630
});
31+
logger.event(LogEvent.TODO_DELETE, { id });
2732
if (onChange) {
2833
onChange(id);
2934
}

lib/javascript/fullstack_demo/src/middleware.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
2+
import { NextResponse } from 'next/server';
3+
import { v4 as uuidv4 } from 'uuid';
24

5+
const CORRELATION_ID_HEADER = 'x-correlation-id';
36
const isProtectedRoute = createRouteMatcher(['/(.*)']);
47

58
export default clerkMiddleware(async (auth, req) => {
9+
const correlationId = uuidv4();
10+
req.headers.set(CORRELATION_ID_HEADER, correlationId);
11+
612
if (isProtectedRoute(req)) await auth.protect();
13+
14+
const response = NextResponse.next();
15+
response.headers.set(CORRELATION_ID_HEADER, correlationId);
16+
return response;
717
});
818

919
export const config = {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
1+
import { LogLevel } from './util/logger';
2+
13
export interface Todo {
24
id: number;
35
text: string;
46
completed: boolean;
57
}
8+
9+
export interface Log {
10+
level: LogLevel;
11+
message: string;
12+
vars: {};
13+
}
14+
15+
export enum LogEvent {
16+
LOG_IN = 'log_in',
17+
LOG_OUT = 'log_out',
18+
TODO_TOGGLE = 'todo_toggle',
19+
TODO_DELETE = 'todo_delete',
20+
TODO_ADD = 'todo_add',
21+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { Log, LogEvent } from '@/models';
2+
import { Logger, LogLevel } from './logger';
3+
4+
const FLUSH_AFTER_SIZE = 15;
5+
const MAX_BATCH_SIZE = 100;
6+
const FLUSH_INTERVAL_MS = 1000 * 5; // 5 seconds
7+
8+
class ClientLogger implements Logger {
9+
private readonly buffer: Log[] = [];
10+
private flushing = false;
11+
12+
constructor() {
13+
setInterval(() => this.flush(), FLUSH_INTERVAL_MS);
14+
}
15+
16+
log(level: LogLevel, message: string, vars = {}): void {
17+
vars = { ...this.getDefaultVars(), ...(vars || {}) };
18+
this.buffer.push({ level, message, vars });
19+
if (this.buffer.length >= FLUSH_AFTER_SIZE && !this.flushing) {
20+
this.flush();
21+
}
22+
}
23+
24+
private getDefaultVars() {
25+
if (typeof window === 'undefined') {
26+
return [];
27+
}
28+
29+
return {
30+
client: {
31+
type: 'web',
32+
userAgent: navigator.userAgent,
33+
location: window.location.href,
34+
},
35+
};
36+
}
37+
38+
async flush(): Promise<void> {
39+
if (this.buffer.length === 0 || this.flushing) {
40+
return;
41+
}
42+
43+
this.flushing = true;
44+
45+
const batch = this.buffer.splice(0, MAX_BATCH_SIZE);
46+
let backoffInMs = FLUSH_INTERVAL_MS;
47+
48+
do {
49+
try {
50+
await this.sendLogs(batch);
51+
this.flushing = false;
52+
} catch (e) {
53+
console.error('Failed to send logs', e);
54+
backoffInMs *= 2;
55+
await this.delay(backoffInMs);
56+
}
57+
} while (this.flushing);
58+
}
59+
60+
async sendLogs(batch: Log[]): Promise<void> {
61+
let endpoint = '/api/log';
62+
if (typeof window === 'undefined') {
63+
endpoint = `${process.env?.NEXT_PUBLIC_API_URL || ''}/api/host`;
64+
}
65+
await fetch(endpoint, {
66+
method: 'POST',
67+
headers: {
68+
'Content-Type': 'application/json',
69+
},
70+
body: JSON.stringify(batch),
71+
});
72+
}
73+
74+
async delay(ms: number): Promise<void> {
75+
return new Promise((resolve) => setTimeout(resolve, ms));
76+
}
77+
78+
debug(format: string, vars = {}): void {
79+
this.log('debug', format, vars);
80+
}
81+
info(format: string, vars = {}): void {
82+
this.log('info', format, vars);
83+
}
84+
error(format: string, vars = {}): void {
85+
this.log('error', format, vars);
86+
}
87+
event(eventId: LogEvent, vars = {}): void {
88+
this.log('info', '', { ...vars, eventId });
89+
}
90+
}
91+
92+
export const logger = new ClientLogger();
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { LogEvent } from '@/models';
2+
3+
export interface Logger {
4+
log(level: LogLevel, message: string, vars?: {}): void;
5+
debug(format: string, vars?: {}): void;
6+
info(format: string, vars?: {}): void;
7+
error(format: string, vars?: {}): void;
8+
event(id: LogEvent, vars?: {}): void;
9+
}
10+
11+
export type LogLevel = 'debug' | 'info' | 'error' | 'warn';

0 commit comments

Comments
 (0)