From 987323b14677fc9987c9fcfada67f0fe6b38cbb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Alves?= Date: Sun, 1 Mar 2026 17:24:26 -0300 Subject: [PATCH 1/5] feat(logging): integrate OpenTelemetry logging with custom logger implementation for enhanced error tracking and monitoring --- apps/backend/package.json | 5 +- apps/backend/src/infra/adapters/logger.ts | 129 ++++++++++++++++-- apps/backend/src/infra/db/seed.ts | 4 +- apps/backend/src/infra/http/client-guard.ts | 20 ++- .../controllers/stripe-webhook-controller.ts | 15 ++ .../controllers/user-import-controller.ts | 17 +++ .../http/controllers/user-items-controller.ts | 14 ++ .../src/infra/http/middlewares/verify-jwt.ts | 11 ++ .../src/infra/http/routes/tmdb-proxy.ts | 15 ++ apps/backend/src/infra/http/server.ts | 65 ++++++++- apps/backend/src/infra/telemetry/otel.ts | 26 +++- apps/backend/src/main.ts | 3 +- apps/backend/src/monitors/new-users.ts | 1 - .../src/monitors/today-new-subscriptions.ts | 1 - .../backend/src/monitors/total-items-added.ts | 1 - .../src/monitors/total-subscriptions.ts | 1 - apps/backend/src/monitors/total-users.ts | 1 - apps/backend/src/workers/worker.ts | 3 +- pnpm-lock.yaml | 61 ++------- 19 files changed, 304 insertions(+), 89 deletions(-) diff --git a/apps/backend/package.json b/apps/backend/package.json index ea39f06f..6face9ab 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -39,11 +39,14 @@ "@fastify/swagger-ui": "^5.2.4", "@kubiks/otel-drizzle": "^2.1.0", "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.211.0", + "@opentelemetry/exporter-logs-otlp-proto": "^0.211.0", "@opentelemetry/exporter-metrics-otlp-proto": "^0.211.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.211.0", "@opentelemetry/host-metrics": "^0.38.2", "@opentelemetry/instrumentation-http": "^0.212.0", "@opentelemetry/resources": "^2.5.0", + "@opentelemetry/sdk-logs": "^0.211.0", "@opentelemetry/sdk-metrics": "^2.5.0", "@opentelemetry/sdk-node": "^0.211.0", "@opentelemetry/semantic-conventions": "^1.39.0", @@ -73,8 +76,6 @@ "jwks-rsa": "^3.1.0", "node-cron": "^4.2.1", "openai": "^6.15.0", - "pino": "^10.1.0", - "pino-pretty": "^13.1.3", "postgres": "^3.4.7", "puppeteer": "^24.34.0", "react": "^19.2.3", diff --git a/apps/backend/src/infra/adapters/logger.ts b/apps/backend/src/infra/adapters/logger.ts index e650780e..9eae4fce 100644 --- a/apps/backend/src/infra/adapters/logger.ts +++ b/apps/backend/src/infra/adapters/logger.ts @@ -1,12 +1,117 @@ -import pino from 'pino' - -export const logger = pino({ - transport: { - target: 'pino-pretty', - options: { - colorize: true, - translateTime: 'SYS:standard', - ignore: 'pid,hostname', - }, - }, -}) +import { SeverityNumber } from '@opentelemetry/api-logs' +import { getOtelLogger } from '@/infra/telemetry/otel' + +type Attrs = Record +type OtelAttrs = Record + +const SEVERITY = { + debug: { number: SeverityNumber.DEBUG, text: 'DEBUG' }, + info: { number: SeverityNumber.INFO, text: 'INFO' }, + warn: { number: SeverityNumber.WARN, text: 'WARN' }, + error: { number: SeverityNumber.ERROR, text: 'ERROR' }, +} as const + +type Level = keyof typeof SEVERITY + +function flattenValue(key: string, value: unknown, out: OtelAttrs) { + if (value === undefined || value === null) return + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + out[key] = value + } else if (value instanceof Error) { + out[`${key}.type`] = value.name + out[`${key}.message`] = value.message + if (value.stack) out[`${key}.stacktrace`] = value.stack + } else if (Array.isArray(value)) { + out[key] = value.map(String).join(', ') + } else { + out[key] = JSON.stringify(value) + } +} + +function toOtelAttrs(obj: Attrs): OtelAttrs { + const out: OtelAttrs = {} + for (const [k, v] of Object.entries(obj)) flattenValue(k, v, out) + return out +} + +/** + * Parses the flexible (attrs?, message, extra?) signature used across the + * codebase into a normalized { message, attributes } pair. + * + * Supported call patterns: + * log('message') + * log({ key: 'val' }, 'message') + * log('message', error) + * log('message', extraData) + */ +function parseArgs( + args: [string | Attrs, (string | unknown)?], + bindings: Attrs +): { message: string; attributes: OtelAttrs } { + const [first, second] = args + const attrs: Attrs = { ...bindings } + let message: string + + if (typeof first === 'object' && first !== null && !(first instanceof Error)) { + Object.assign(attrs, first) + message = typeof second === 'string' ? second : '' + } else { + message = String(first) + if (second !== undefined) { + if (second instanceof Error) { + flattenValue('err', second, attrs as unknown as OtelAttrs) + } else { + flattenValue('detail', second, attrs as unknown as OtelAttrs) + } + } + } + + return { message, attributes: toOtelAttrs(attrs) } +} + +function emit(level: Level, message: string, attributes: OtelAttrs) { + const { number: severityNumber, text: severityText } = SEVERITY[level] + + getOtelLogger().emit({ + severityNumber, + severityText, + body: message, + attributes, + }) + + const line = JSON.stringify({ level: severityText, msg: message, ...attributes, time: new Date().toISOString() }) + process.stdout.write(`${line}\n`) +} + +export interface Logger { + debug(message: string): void + debug(attrs: Attrs, message: string): void + info(message: string): void + info(attrs: Attrs, message: string): void + info(message: string, detail: unknown): void + warn(message: string): void + warn(attrs: Attrs, message: string): void + error(message: string): void + error(attrs: Attrs, message: string): void + error(message: string, error: unknown): void + child(bindings: Attrs): Logger +} + +function createLogger(bindings: Attrs = {}): Logger { + function log(level: Level) { + return (...args: [string | Attrs, (string | unknown)?]) => { + const { message, attributes } = parseArgs(args, bindings) + emit(level, message, attributes) + } + } + + return { + debug: log('debug'), + info: log('info'), + warn: log('warn'), + error: log('error'), + child: (childBindings: Attrs) => createLogger({ ...bindings, ...childBindings }), + } +} + +export const logger = createLogger() diff --git a/apps/backend/src/infra/db/seed.ts b/apps/backend/src/infra/db/seed.ts index c742ab5c..986c0166 100644 --- a/apps/backend/src/infra/db/seed.ts +++ b/apps/backend/src/infra/db/seed.ts @@ -6,5 +6,7 @@ async function main() { } main() - .catch(err => console.error(err)) + .catch(err => { + logger.error('Database seed failed', err) + }) .finally(() => client.end()) diff --git a/apps/backend/src/infra/http/client-guard.ts b/apps/backend/src/infra/http/client-guard.ts index e43a8f8d..8e6ed9f9 100644 --- a/apps/backend/src/infra/http/client-guard.ts +++ b/apps/backend/src/infra/http/client-guard.ts @@ -1,6 +1,7 @@ import { timingSafeEqual } from 'node:crypto' import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import { config } from '@/config' +import { logger } from '@/infra/adapters/logger' const SKIP_PATHS = ['/health', '/complete-stripe-subscription'] const ALLOWED_ORIGINS = ['https://plotwist.app'] @@ -27,7 +28,18 @@ function allowedOrigin( ) } -function forbidden(reply: FastifyReply) { +function forbidden(request: FastifyRequest, reply: FastifyReply) { + logger.warn( + { + method: request.method, + url: request.url, + path: request.url.split('?')[0], + origin: request.headers.origin, + referer: request.headers.referer, + statusCode: 403, + }, + 'HTTP 403: Request not allowed from this client' + ) return reply.status(403).send({ statusCode: 403, error: 'Forbidden', @@ -59,17 +71,17 @@ export function registerClientGuard(app: FastifyInstance) { const iosToken = request.headers['x-ios-token'] if (typeof iosToken === 'string' && iosToken.length > 0) { - if (!validIosToken(request, app)) return forbidden(reply) + if (!validIosToken(request, app)) return forbidden(request, reply) return } const androidToken = request.headers['x-android-token'] if (typeof androidToken === 'string' && androidToken.length > 0) { app.log.warn('X-Android-Token received but not implemented yet.') - return forbidden(reply) + return forbidden(request, reply) } if (allowedOrigin(request.headers.origin, request.headers.referer)) return - return forbidden(reply) + return forbidden(request, reply) }) } diff --git a/apps/backend/src/infra/http/controllers/stripe-webhook-controller.ts b/apps/backend/src/infra/http/controllers/stripe-webhook-controller.ts index 4ef54c34..1de60811 100644 --- a/apps/backend/src/infra/http/controllers/stripe-webhook-controller.ts +++ b/apps/backend/src/infra/http/controllers/stripe-webhook-controller.ts @@ -19,6 +19,10 @@ export async function stripeWebhookController( ) { const stripeSignature = request.headers['stripe-signature'] if (!stripeSignature) { + logger.warn( + { method: request.method, url: request.url, statusCode: 400 }, + 'Stripe webhook: missing signature' + ) return reply.status(400).send('Missing Stripe signature.') } @@ -31,6 +35,17 @@ export async function stripeWebhookController( webhookSecret ) } catch (error) { + logger.error( + { + err: error instanceof Error ? error : new Error(String(error)), + method: request.method, + url: request.url, + route: request.routeOptions?.url, + userId: request.user?.id, + statusCode: 400, + }, + 'Stripe webhook: signature verification failed' + ) return reply.status(400).send(`Webhook Error: ${error}`) } diff --git a/apps/backend/src/infra/http/controllers/user-import-controller.ts b/apps/backend/src/infra/http/controllers/user-import-controller.ts index 1415779e..13ea8dba 100644 --- a/apps/backend/src/infra/http/controllers/user-import-controller.ts +++ b/apps/backend/src/infra/http/controllers/user-import-controller.ts @@ -3,6 +3,7 @@ import { providerDispatcher } from '@/domain/dispatchers/import-dispatcher' import { DomainError } from '@/domain/errors/domain-error' import { getDetailedUserImportById } from '@/domain/services/imports/get-detailed-user-import-by-id' import { publishToQueue } from '@/domain/services/imports/publish-import-to-queue' +import { logger } from '@/infra/adapters/logger' import { createImportRequestSchema, getDetailedImportRequestSchema, @@ -23,6 +24,10 @@ export async function createImportController( const userId = request.user.id if (!uploadedFile) { + logger.warn( + { method: request.method, url: request.url, userId, statusCode: 400 }, + 'Import: invalid file provided' + ) return reply.status(400).send({ message: 'Invalid file provided.' }) } @@ -37,6 +42,18 @@ export async function createImportController( return reply.status(200).send({ message: 'File processed successfully.' }) } catch (error) { + logger.error( + { + err: error instanceof Error ? error : new Error(String(error)), + method: request.method, + url: request.url, + route: request.routeOptions?.url, + userId, + provider, + statusCode: 500, + }, + 'Import: file processing failed' + ) return reply.status(500).send({ message: `An error occurred while processing the file: ${error instanceof Error ? error.message : String(error)}`, }) diff --git a/apps/backend/src/infra/http/controllers/user-items-controller.ts b/apps/backend/src/infra/http/controllers/user-items-controller.ts index 24372452..9e20a29b 100644 --- a/apps/backend/src/infra/http/controllers/user-items-controller.ts +++ b/apps/backend/src/infra/http/controllers/user-items-controller.ts @@ -18,6 +18,7 @@ import { deleteWatchEntriesByUserItemId, getWatchEntriesByUserItemId, } from '@/infra/db/repositories/user-watch-entries-repository' +import { logger } from '@/infra/adapters/logger' import { deleteUserItemParamsSchema, getAllUserItemsQuerySchema, @@ -101,6 +102,19 @@ export async function upsertUserItemController( const userItem = result.userItem if (!userItem) { + logger.error( + { + method: request.method, + url: request.url, + route: request.routeOptions?.url, + userId: request.user.id, + tmdbId, + mediaType, + status, + statusCode: 500, + }, + 'User item could not be retrieved after upsert' + ) return reply.status(500).send({ statusCode: 500, error: 'Internal Server Error', diff --git a/apps/backend/src/infra/http/middlewares/verify-jwt.ts b/apps/backend/src/infra/http/middlewares/verify-jwt.ts index e476bcd9..ebd3c043 100644 --- a/apps/backend/src/infra/http/middlewares/verify-jwt.ts +++ b/apps/backend/src/infra/http/middlewares/verify-jwt.ts @@ -1,9 +1,20 @@ import type { FastifyReply, FastifyRequest } from 'fastify' +import { logger } from '@/infra/adapters/logger' export async function verifyJwt(request: FastifyRequest, reply: FastifyReply) { try { await request.jwtVerify() } catch (err) { + logger.warn( + { + err: err instanceof Error ? err : new Error(String(err)), + method: request.method, + url: request.url, + route: request.routeOptions?.url, + statusCode: 401, + }, + 'HTTP 401: Unauthorized' + ) return reply.status(401).send({ message: `Unauthorized: ${err instanceof Error ? err.message : String(err)}`, }) diff --git a/apps/backend/src/infra/http/routes/tmdb-proxy.ts b/apps/backend/src/infra/http/routes/tmdb-proxy.ts index 457141cc..0d313a05 100644 --- a/apps/backend/src/infra/http/routes/tmdb-proxy.ts +++ b/apps/backend/src/infra/http/routes/tmdb-proxy.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify' import https from 'https' import { config } from '@/config' +import { logger } from '@/infra/adapters/logger' const TMDB_BASE_URL = 'https://api.themoviedb.org/3' @@ -90,6 +91,10 @@ export async function tmdbProxyRoutes(app: FastifyInstance) { const tmdbPath = (request.params as { '*': string })['*'] if (!tmdbPath) { + logger.warn( + { method: request.method, url: request.url, statusCode: 400 }, + 'TMDB proxy: missing path' + ) return reply.status(400).send({ error: 'Missing TMDB path' }) } @@ -139,6 +144,16 @@ export async function tmdbProxyRoutes(app: FastifyInstance) { // 4. Handle TMDB errors if (tmdbResponse.statusCode !== 200) { + logger.error( + { + method: request.method, + url: request.url, + tmdbPath, + tmdbStatusCode: tmdbResponse.statusCode, + statusCode: tmdbResponse.statusCode, + }, + 'TMDB proxy: upstream API error' + ) return reply.status(tmdbResponse.statusCode).send({ error: 'TMDB API error', statusCode: tmdbResponse.statusCode, diff --git a/apps/backend/src/infra/http/server.ts b/apps/backend/src/infra/http/server.ts index 5853644b..bfcb473e 100644 --- a/apps/backend/src/infra/http/server.ts +++ b/apps/backend/src/infra/http/server.ts @@ -50,23 +50,42 @@ export async function startServer() { try { return transformSwaggerSchema(schema) } catch (err) { - if (err instanceof Error) { - console.error({ error: err.message }) - } - + logger.error( + { err: err instanceof Error ? err : new Error(String(err)) }, + 'Swagger schema transform failed' + ) return schema } }, }) - app.setErrorHandler((error, _, reply) => { + app.setErrorHandler((error, request, reply) => { if (error instanceof ZodError) { + logger.warn( + { + err: error, + method: request.method, + url: request.url, + route: request.routeOptions?.url, + statusCode: 400, + }, + 'HTTP 400: Validation error' + ) return reply .status(400) .send({ message: 'Validation error.', issues: error.format() }) } if (error instanceof DomainError && error.status === 429) { + logger.warn( + { + method: request.method, + url: request.url, + route: request.routeOptions?.url, + statusCode: 429, + }, + 'HTTP 429: Rate limit' + ) return reply .code(429) .send({ message: 'You hit the rate limit! Slow down please!' }) @@ -77,6 +96,15 @@ export async function startServer() { (error as { statusCode: number }).statusCode === 429 ) { if (!reply.sent) { + logger.warn( + { + method: request.method, + url: request.url, + route: request.routeOptions?.url, + statusCode: 429, + }, + 'HTTP 429: Rate limit' + ) return reply .code(429) .send( @@ -86,11 +114,21 @@ export async function startServer() { return } - console.error({ error }) + ;(request as { _serverError?: unknown })._serverError = error + logger.error( + { + err: error instanceof Error ? error : new Error(String(error)), + method: request.method, + url: request.url, + route: request.routeOptions?.url, + statusCode: 500, + }, + 'HTTP 500: Internal server error' + ) return reply.status(500).send({ message: 'Internal server error.' }) }) - app.addHook('onResponse', (_request, reply, done) => { + app.addHook('onResponse', (request, reply, done) => { const span = trace.getActiveSpan() if (span) { if (reply.statusCode >= 500) { @@ -98,6 +136,19 @@ export async function startServer() { code: SpanStatusCode.ERROR, message: `HTTP ${reply.statusCode}`, }) + span.setAttribute('error', true) + span.setAttribute('http.response.status_code', reply.statusCode) + span.setAttribute('error.type', String(reply.statusCode)) + + const serverError = (request as { _serverError?: unknown })._serverError + if (serverError instanceof Error) { + span.setAttribute('exception.type', serverError.name) + span.setAttribute('exception.message', serverError.message) + if (serverError.stack) { + span.setAttribute('exception.stacktrace', serverError.stack) + } + span.recordException(serverError) + } } else { span.setStatus({ code: SpanStatusCode.OK }) } diff --git a/apps/backend/src/infra/telemetry/otel.ts b/apps/backend/src/infra/telemetry/otel.ts index ae3bcb4e..85e6e3d4 100644 --- a/apps/backend/src/infra/telemetry/otel.ts +++ b/apps/backend/src/infra/telemetry/otel.ts @@ -1,10 +1,13 @@ import FastifyOtel from '@fastify/otel' import { metrics, trace } from '@opentelemetry/api' +import { logs } from '@opentelemetry/api-logs' +import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-proto' import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto' import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' import { HostMetrics } from '@opentelemetry/host-metrics' import { HttpInstrumentation } from '@opentelemetry/instrumentation-http' import { resourceFromAttributes } from '@opentelemetry/resources' +import { BatchLogRecordProcessor, LoggerProvider } from '@opentelemetry/sdk-logs' import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' import { NodeSDK } from '@opentelemetry/sdk-node' import { @@ -12,7 +15,6 @@ import { ATTR_SERVICE_VERSION, } from '@opentelemetry/semantic-conventions' import { config } from '@/config' -import { logger } from '../adapters/logger' const LOCALHOST_OTLP = 'http://localhost:4318' @@ -31,6 +33,7 @@ function getOtlpConfig() { return { metricsUrl: `${base}/v1/metrics`, tracesUrl: `${base}/v1/traces`, + logsUrl: `${base}/v1/logs`, headers, } } @@ -58,7 +61,7 @@ function parseOtlpHeaders(raw: string): Record { return out } -const { metricsUrl, tracesUrl, headers } = getOtlpConfig() +const { metricsUrl, tracesUrl, logsUrl, headers } = getOtlpConfig() const httpInstrumentation = new HttpInstrumentation() @@ -74,7 +77,22 @@ const sdk = new NodeSDK({ instrumentations: [httpInstrumentation], }) -logger.info('Starting OTLP exporter') +const logResource = resourceFromAttributes({ + [ATTR_SERVICE_NAME]: 'plotwist-api', + [ATTR_SERVICE_VERSION]: '0.1.0', +}) +const logExporter = new OTLPLogExporter({ url: logsUrl, headers }) +const loggerProvider = new LoggerProvider({ + resource: logResource, + processors: [new BatchLogRecordProcessor(logExporter)], +}) +logs.setGlobalLoggerProvider(loggerProvider) + +function getOtelLogger() { + return logs.getLogger('plotwist-api', '0.1.0') +} + +console.log('Starting OTLP exporter (traces, metrics, logs)') sdk.start() @@ -94,4 +112,4 @@ hostMetrics.start() const fastifyOtel = new FastifyOtel() fastifyOtel.setTracerProvider(trace.getTracerProvider()) -export { fastifyOtel } +export { fastifyOtel, getOtelLogger } diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index d07ac62b..931320fb 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -1,5 +1,6 @@ import '@/infra/telemetry/otel' +import { logger } from '@/infra/adapters/logger' import { startServer } from '@/infra/http/server' import { startMonitors } from '@/monitors/monitor' import { startWorkers } from '@/workers/worker' @@ -11,5 +12,5 @@ async function main() { } main().catch(err => { - console.error('Error initializing Plotwist', err) + logger.error('Error initializing Plotwist', err) }) diff --git a/apps/backend/src/monitors/new-users.ts b/apps/backend/src/monitors/new-users.ts index 71a42e5f..19343102 100644 --- a/apps/backend/src/monitors/new-users.ts +++ b/apps/backend/src/monitors/new-users.ts @@ -12,6 +12,5 @@ export async function monitorTodayNewUsers() { ), ]) - console.log(`Today new users: ${totalNewUsers}`) return totalNewUsers } diff --git a/apps/backend/src/monitors/today-new-subscriptions.ts b/apps/backend/src/monitors/today-new-subscriptions.ts index 0b92f6a9..e3653f6a 100644 --- a/apps/backend/src/monitors/today-new-subscriptions.ts +++ b/apps/backend/src/monitors/today-new-subscriptions.ts @@ -12,6 +12,5 @@ export async function monitorTodayNewSubscriptions() { ), ]) - console.log(`Today new subscriptions: ${totalNewSubscriptions}`) return totalNewSubscriptions } diff --git a/apps/backend/src/monitors/total-items-added.ts b/apps/backend/src/monitors/total-items-added.ts index dd96a08e..e2241c0d 100644 --- a/apps/backend/src/monitors/total-items-added.ts +++ b/apps/backend/src/monitors/total-items-added.ts @@ -7,6 +7,5 @@ export async function monitorTotalItemsAdded() { db.select({ count: sql`count(*)::int` }).from(schema.userItems), ]) - console.log(`Total items added: ${totalItemsAdded}`) return totalItemsAdded } diff --git a/apps/backend/src/monitors/total-subscriptions.ts b/apps/backend/src/monitors/total-subscriptions.ts index 56bbded4..6a8f12ea 100644 --- a/apps/backend/src/monitors/total-subscriptions.ts +++ b/apps/backend/src/monitors/total-subscriptions.ts @@ -7,6 +7,5 @@ export async function monitorTotalSubscriptions() { db.select({ count: sql`count(*)::int` }).from(schema.subscriptions), ]) - console.log(`Total subscriptions: ${totalSubscriptions}`) return totalSubscriptions } diff --git a/apps/backend/src/monitors/total-users.ts b/apps/backend/src/monitors/total-users.ts index 5568891b..60f3fa9c 100644 --- a/apps/backend/src/monitors/total-users.ts +++ b/apps/backend/src/monitors/total-users.ts @@ -7,6 +7,5 @@ export async function monitorTotalUsers() { db.select({ count: sql`count(*)::int` }).from(schema.users), ]) - console.log(`Total users: ${totalUsers}`) return totalUsers } diff --git a/apps/backend/src/workers/worker.ts b/apps/backend/src/workers/worker.ts index 4bf73a34..9d4efbd3 100644 --- a/apps/backend/src/workers/worker.ts +++ b/apps/backend/src/workers/worker.ts @@ -1,4 +1,5 @@ import { config } from '@/config' +import { logger } from '@/infra/adapters/logger' import { createSqsClient, initializeSQS } from '@/infra/adapters/sqs' import { startMovieConsumer } from '@/infra/consumers/movies-consumer' import { startSeriesConsumer } from '@/infra/consumers/series-consumer' @@ -24,7 +25,7 @@ async function startSQS() { await initializeSQS(sqsClient) startConsumers().catch(error => { - console.error('Error starting consumers:', error) + logger.error('Error starting consumers', error) process.exit(1) }) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7513941a..59aca6d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,12 @@ importers: '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 + '@opentelemetry/api-logs': + specifier: ^0.211.0 + version: 0.211.0 + '@opentelemetry/exporter-logs-otlp-proto': + specifier: ^0.211.0 + version: 0.211.0(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-metrics-otlp-proto': specifier: ^0.211.0 version: 0.211.0(@opentelemetry/api@1.9.0) @@ -80,6 +86,9 @@ importers: '@opentelemetry/resources': specifier: ^2.5.0 version: 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': + specifier: ^0.211.0 + version: 0.211.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': specifier: ^2.5.0 version: 2.5.1(@opentelemetry/api@1.9.0) @@ -167,12 +176,6 @@ importers: openai: specifier: ^6.15.0 version: 6.23.0(ws@8.19.0)(zod@4.3.6) - pino: - specifier: ^10.1.0 - version: 10.3.1 - pino-pretty: - specifier: ^13.1.3 - version: 13.1.3 postgres: specifier: ^3.4.7 version: 3.4.8 @@ -5215,9 +5218,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - colorette@2.0.20: - resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -5471,9 +5471,6 @@ packages: date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} - dateformat@4.6.3: - resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} - debounce-fn@6.0.0: resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==} engines: {node: '>=18'} @@ -5946,9 +5943,6 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true - fast-copy@4.0.2: - resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} - fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -6282,9 +6276,6 @@ packages: headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} - help-me@5.0.0: - resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -7492,10 +7483,6 @@ packages: pino-abstract-transport@3.0.0: resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} - pino-pretty@13.1.3: - resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} - hasBin: true - pino-std-serializers@7.1.0: resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} @@ -8325,10 +8312,6 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} - strip-json-comments@5.0.3: - resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} - engines: {node: '>=14.16'} - stripe@20.3.1: resolution: {integrity: sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==} engines: {node: '>=16'} @@ -14267,8 +14250,6 @@ snapshots: color-name@1.1.4: {} - colorette@2.0.20: {} - combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -14524,8 +14505,6 @@ snapshots: date-fns@4.1.0: {} - dateformat@4.6.3: {} - debounce-fn@6.0.0: dependencies: mimic-function: 5.0.1 @@ -15058,8 +15037,6 @@ snapshots: transitivePeerDependencies: - supports-color - fast-copy@4.0.2: {} - fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -15478,8 +15455,6 @@ snapshots: headers-polyfill@4.0.3: {} - help-me@5.0.0: {} - html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.14.1 @@ -16910,22 +16885,6 @@ snapshots: dependencies: split2: 4.2.0 - pino-pretty@13.1.3: - dependencies: - colorette: 2.0.20 - dateformat: 4.6.3 - fast-copy: 4.0.2 - fast-safe-stringify: 2.1.1 - help-me: 5.0.0 - joycon: 3.1.1 - minimist: 1.2.8 - on-exit-leak-free: 2.1.2 - pino-abstract-transport: 3.0.0 - pump: 3.0.3 - secure-json-parse: 4.1.0 - sonic-boom: 4.2.1 - strip-json-comments: 5.0.3 - pino-std-serializers@7.1.0: {} pino@10.3.1: @@ -17980,8 +17939,6 @@ snapshots: strip-final-newline@2.0.0: {} - strip-json-comments@5.0.3: {} - stripe@20.3.1(@types/node@25.3.0): optionalDependencies: '@types/node': 25.3.0 From 5689beff7bbefcd543ad45d24b477c58fce3c897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Alves?= Date: Sun, 1 Mar 2026 17:34:16 -0300 Subject: [PATCH 2/5] feat(telemetry): enhance error logging by including user ID in error responses and telemetry traces --- apps/backend/src/infra/http/client-guard.ts | 1 + apps/backend/src/infra/http/middlewares/verify-jwt.ts | 5 +++++ .../src/infra/http/middlewares/verify-optional-jwt.ts | 5 +++++ apps/backend/src/infra/http/routes/tmdb-proxy.ts | 8 +++++++- apps/backend/src/infra/http/server.ts | 9 +++++++++ 5 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/infra/http/client-guard.ts b/apps/backend/src/infra/http/client-guard.ts index 8e6ed9f9..57112dbe 100644 --- a/apps/backend/src/infra/http/client-guard.ts +++ b/apps/backend/src/infra/http/client-guard.ts @@ -37,6 +37,7 @@ function forbidden(request: FastifyRequest, reply: FastifyReply) { origin: request.headers.origin, referer: request.headers.referer, statusCode: 403, + userId: (request as { user?: { id: string } }).user?.id, }, 'HTTP 403: Request not allowed from this client' ) diff --git a/apps/backend/src/infra/http/middlewares/verify-jwt.ts b/apps/backend/src/infra/http/middlewares/verify-jwt.ts index ebd3c043..ab46590b 100644 --- a/apps/backend/src/infra/http/middlewares/verify-jwt.ts +++ b/apps/backend/src/infra/http/middlewares/verify-jwt.ts @@ -1,9 +1,14 @@ import type { FastifyReply, FastifyRequest } from 'fastify' +import { trace } from '@opentelemetry/api' import { logger } from '@/infra/adapters/logger' export async function verifyJwt(request: FastifyRequest, reply: FastifyReply) { try { await request.jwtVerify() + const userId = (request as { user?: { id: string } }).user?.id + if (userId) { + trace.getActiveSpan()?.setAttribute('user.id', userId) + } } catch (err) { logger.warn( { diff --git a/apps/backend/src/infra/http/middlewares/verify-optional-jwt.ts b/apps/backend/src/infra/http/middlewares/verify-optional-jwt.ts index 0de331a7..fc405876 100644 --- a/apps/backend/src/infra/http/middlewares/verify-optional-jwt.ts +++ b/apps/backend/src/infra/http/middlewares/verify-optional-jwt.ts @@ -1,8 +1,13 @@ import type { FastifyRequest } from 'fastify' +import { trace } from '@opentelemetry/api' export async function verifyOptionalJwt(request: FastifyRequest) { try { await request.jwtVerify() + const userId = (request as { user?: { id: string } }).user?.id + if (userId) { + trace.getActiveSpan()?.setAttribute('user.id', userId) + } } catch (err) { return err instanceof Error ? err : new Error(String(err)) } diff --git a/apps/backend/src/infra/http/routes/tmdb-proxy.ts b/apps/backend/src/infra/http/routes/tmdb-proxy.ts index 0d313a05..777020b0 100644 --- a/apps/backend/src/infra/http/routes/tmdb-proxy.ts +++ b/apps/backend/src/infra/http/routes/tmdb-proxy.ts @@ -92,7 +92,12 @@ export async function tmdbProxyRoutes(app: FastifyInstance) { if (!tmdbPath) { logger.warn( - { method: request.method, url: request.url, statusCode: 400 }, + { + method: request.method, + url: request.url, + statusCode: 400, + userId: (request as { user?: { id: string } }).user?.id, + }, 'TMDB proxy: missing path' ) return reply.status(400).send({ error: 'Missing TMDB path' }) @@ -151,6 +156,7 @@ export async function tmdbProxyRoutes(app: FastifyInstance) { tmdbPath, tmdbStatusCode: tmdbResponse.statusCode, statusCode: tmdbResponse.statusCode, + userId: (request as { user?: { id: string } }).user?.id, }, 'TMDB proxy: upstream API error' ) diff --git a/apps/backend/src/infra/http/server.ts b/apps/backend/src/infra/http/server.ts index bfcb473e..ceb0bcc1 100644 --- a/apps/backend/src/infra/http/server.ts +++ b/apps/backend/src/infra/http/server.ts @@ -17,6 +17,10 @@ import { transformSwaggerSchema } from './transform-schema' const app: FastifyInstance = fastify() +function getUserId(request: { user?: { id: string } }): string | undefined { + return request.user?.id +} + export async function startServer() { await app.register(fastifyOtel.plugin()) @@ -60,6 +64,7 @@ export async function startServer() { }) app.setErrorHandler((error, request, reply) => { + const userId = getUserId(request as { user?: { id: string } }) if (error instanceof ZodError) { logger.warn( { @@ -68,6 +73,7 @@ export async function startServer() { url: request.url, route: request.routeOptions?.url, statusCode: 400, + userId, }, 'HTTP 400: Validation error' ) @@ -83,6 +89,7 @@ export async function startServer() { url: request.url, route: request.routeOptions?.url, statusCode: 429, + userId: getUserId(request as { user?: { id: string } }), }, 'HTTP 429: Rate limit' ) @@ -102,6 +109,7 @@ export async function startServer() { url: request.url, route: request.routeOptions?.url, statusCode: 429, + userId: getUserId(request as { user?: { id: string } }), }, 'HTTP 429: Rate limit' ) @@ -122,6 +130,7 @@ export async function startServer() { url: request.url, route: request.routeOptions?.url, statusCode: 500, + userId: getUserId(request as { user?: { id: string } }), }, 'HTTP 500: Internal server error' ) From 2521dd597df87a02847cf7a7ee4ac819d340518f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Alves?= Date: Sun, 1 Mar 2026 17:47:31 -0300 Subject: [PATCH 3/5] refactor(user-stats): update import statements for consistency and improve test coverage --- .../user-stats/get-user-best-reviews.ts | 2 +- .../get-user-most-watched-series.ts | 2 +- .../user-stats/get-user-total-hours.spec.ts | 79 ++++++++++++++++++- .../user-stats/get-user-total-hours.ts | 65 +++++++++------ .../user-stats/get-user-watched-cast.ts | 9 ++- .../user-stats/get-user-watched-countries.ts | 9 ++- apps/backend/src/infra/adapters/logger.ts | 22 +++++- .../http/controllers/user-items-controller.ts | 2 +- .../src/infra/http/controllers/user-stats.ts | 2 +- .../src/infra/http/middlewares/verify-jwt.ts | 2 +- .../http/middlewares/verify-optional-jwt.ts | 2 +- apps/backend/src/infra/http/schemas/common.ts | 7 +- apps/backend/src/infra/telemetry/otel.ts | 5 +- apps/web/src/app/[lang]/page.tsx | 1 - apps/web/src/app/layout.tsx | 5 +- apps/web/src/components/structured-data.tsx | 7 +- apps/web/src/utils/seo.ts | 3 +- 17 files changed, 165 insertions(+), 59 deletions(-) diff --git a/apps/backend/src/domain/services/user-stats/get-user-best-reviews.ts b/apps/backend/src/domain/services/user-stats/get-user-best-reviews.ts index 7ce03dc1..1c7e875d 100644 --- a/apps/backend/src/domain/services/user-stats/get-user-best-reviews.ts +++ b/apps/backend/src/domain/services/user-stats/get-user-best-reviews.ts @@ -1,7 +1,7 @@ import type { FastifyRedis } from '@fastify/redis' import type { Language } from '@plotwist_app/tmdb' -import type { StatsPeriod } from '@/infra/http/schemas/common' import { selectBestReviews } from '@/infra/db/repositories/reviews-repository' +import type { StatsPeriod } from '@/infra/http/schemas/common' import { getTMDBMovieService } from '../tmdb/get-tmdb-movie' import { getTMDBTvSeriesService } from '../tmdb/get-tmdb-tv-series' import { processInBatches } from './batch-utils' diff --git a/apps/backend/src/domain/services/user-stats/get-user-most-watched-series.ts b/apps/backend/src/domain/services/user-stats/get-user-most-watched-series.ts index 0dd4b777..70609096 100644 --- a/apps/backend/src/domain/services/user-stats/get-user-most-watched-series.ts +++ b/apps/backend/src/domain/services/user-stats/get-user-most-watched-series.ts @@ -1,7 +1,7 @@ import type { FastifyRedis } from '@fastify/redis' import type { Language } from '@plotwist_app/tmdb' -import type { StatsPeriod } from '@/infra/http/schemas/common' import { selectMostWatched } from '@/infra/db/repositories/user-episode' +import type { StatsPeriod } from '@/infra/http/schemas/common' import { getTMDBTvSeriesService } from '../tmdb/get-tmdb-tv-series' import { processInBatches } from './batch-utils' diff --git a/apps/backend/src/domain/services/user-stats/get-user-total-hours.spec.ts b/apps/backend/src/domain/services/user-stats/get-user-total-hours.spec.ts index 6e35522c..90d420ed 100644 --- a/apps/backend/src/domain/services/user-stats/get-user-total-hours.spec.ts +++ b/apps/backend/src/domain/services/user-stats/get-user-total-hours.spec.ts @@ -131,12 +131,89 @@ describe('get user total hours count', () => { movieHours: INCEPTION.runtime, seriesHours: CHERNOBYL.runtime, }) - expect(sut.monthlyHours).toHaveLength(6) + expect(sut.monthlyHours).toHaveLength(12) expect( sut.monthlyHours.every( (m: { month: string; hours: number }) => typeof m.month === 'string' && typeof m.hours === 'number' ) ).toBe(true) + expect(Array.isArray(sut.dailyActivity)).toBe(true) + expect( + sut.dailyActivity.every( + (d: { day: string; hours: number }) => + typeof d.day === 'string' && typeof d.hours === 'number' + ) + ).toBe(true) + expect(sut.dailyActivity.length).toBeGreaterThan(0) + }) + + it('should return empty dailyActivity for period "all" when user has no watched items', async () => { + const user = await makeUser() + + const sut = await getUserTotalHoursService(user.id, redisClient, 'all') + + expect(sut.dailyActivity).toEqual([]) + }) + + it('should return dailyActivity array for period "month"', async () => { + const user = await makeUser() + + await makeUserItem({ + userId: user.id, + tmdbId: INCEPTION.tmdbId, + mediaType: INCEPTION.mediaType, + status: 'WATCHED', + }) + + const sut = await getUserTotalHoursService(user.id, redisClient, 'month') + + expect(Array.isArray(sut.dailyActivity)).toBe(true) + expect( + sut.dailyActivity.every( + (d: { day: string; hours: number }) => + typeof d.day === 'string' && typeof d.hours === 'number' + ) + ).toBe(true) + expect(sut.movieHours).toBe(INCEPTION.runtime) + }) + + it('should return dailyActivity array for period "year"', async () => { + const user = await makeUser() + + await makeUserItem({ + userId: user.id, + tmdbId: INCEPTION.tmdbId, + mediaType: INCEPTION.mediaType, + status: 'WATCHED', + }) + + const sut = await getUserTotalHoursService(user.id, redisClient, 'year') + + expect(Array.isArray(sut.dailyActivity)).toBe(true) + expect( + sut.dailyActivity.every( + (d: { day: string; hours: number }) => + typeof d.day === 'string' && typeof d.hours === 'number' + ) + ).toBe(true) + }) + + it('should return dailyActivity array for period "last_month"', async () => { + const user = await makeUser() + + const sut = await getUserTotalHoursService( + user.id, + redisClient, + 'last_month' + ) + + expect(Array.isArray(sut.dailyActivity)).toBe(true) + expect( + sut.dailyActivity.every( + (d: { day: string; hours: number }) => + typeof d.day === 'string' && typeof d.hours === 'number' + ) + ).toBe(true) }) }) diff --git a/apps/backend/src/domain/services/user-stats/get-user-total-hours.ts b/apps/backend/src/domain/services/user-stats/get-user-total-hours.ts index c8211f05..d64c7af5 100644 --- a/apps/backend/src/domain/services/user-stats/get-user-total-hours.ts +++ b/apps/backend/src/domain/services/user-stats/get-user-total-hours.ts @@ -200,39 +200,52 @@ function computeDailyBreakdown( })) } +function getDateRangeForPeriod( + period: StatsPeriod, + now: Date, + movieData: { date: Date | null }[], + episodes: { watchedAt: Date | null }[] +): { startDate: Date; endDate: Date } | null { + if (period === 'month') { + return { + startDate: new Date(now.getFullYear(), now.getMonth(), 1), + endDate: now, + } + } + if (period === 'last_month') { + return { + startDate: new Date(now.getFullYear(), now.getMonth() - 1, 1), + endDate: new Date(now.getFullYear(), now.getMonth(), 0), + } + } + if (period === 'year') { + return { + startDate: new Date(now.getFullYear(), 0, 1), + endDate: now, + } + } + const allTimestamps = [ + ...movieData.flatMap(m => (m.date ? [m.date.getTime()] : [])), + ...episodes.flatMap(e => (e.watchedAt ? [e.watchedAt.getTime()] : [])), + ] + if (allTimestamps.length === 0) return null + const start = new Date(Math.min(...allTimestamps)) + return { + startDate: new Date(start.getFullYear(), start.getMonth(), start.getDate()), + endDate: now, + } +} + function computeAllDailyActivity( movieData: { runtime: number; date: Date | null }[], episodes: { runtime: number; watchedAt: Date | null }[], period: StatsPeriod ) { const now = new Date() - let startDate: Date - let endDate: Date - - if (period === 'month') { - startDate = new Date(now.getFullYear(), now.getMonth(), 1) - endDate = now - } else if (period === 'last_month') { - startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1) - endDate = new Date(now.getFullYear(), now.getMonth(), 0) - } else if (period === 'year') { - startDate = new Date(now.getFullYear(), 0, 1) - endDate = now - } else { - const allDates = [ - ...movieData.filter(m => m.date).map(m => m.date!.getTime()), - ...episodes.filter(e => e.watchedAt).map(e => e.watchedAt!.getTime()), - ] - if (allDates.length === 0) return [] - startDate = new Date(Math.min(...allDates)) - startDate = new Date( - startDate.getFullYear(), - startDate.getMonth(), - startDate.getDate() - ) - endDate = now - } + const range = getDateRangeForPeriod(period, now, movieData, episodes) + if (!range) return [] + const { startDate, endDate } = range const dayMap = new Map() const cursor = new Date(startDate) while (cursor <= endDate) { diff --git a/apps/backend/src/domain/services/user-stats/get-user-watched-cast.ts b/apps/backend/src/domain/services/user-stats/get-user-watched-cast.ts index b1f2cfc6..0dd91b2d 100644 --- a/apps/backend/src/domain/services/user-stats/get-user-watched-cast.ts +++ b/apps/backend/src/domain/services/user-stats/get-user-watched-cast.ts @@ -1,6 +1,6 @@ import type { FastifyRedis } from '@fastify/redis' -import type { StatsPeriod } from '@/infra/http/schemas/common' import { selectAllUserItemsByStatus } from '@/infra/db/repositories/user-item-repository' +import type { StatsPeriod } from '@/infra/http/schemas/common' import { getTMDBCredits } from '../tmdb/get-tmdb-credits' import { processInBatches } from './batch-utils' import { getCachedStats, getUserStatsCacheKey } from './cache-utils' @@ -18,7 +18,12 @@ export async function getUserWatchedCastService({ dateRange, period = 'all', }: GetUserWatchedCastServiceInput) { - const cacheKey = getUserStatsCacheKey(userId, 'watched-cast', undefined, period) + const cacheKey = getUserStatsCacheKey( + userId, + 'watched-cast', + undefined, + period + ) return getCachedStats(redis, cacheKey, async () => { const watchedItems = await selectAllUserItemsByStatus({ diff --git a/apps/backend/src/domain/services/user-stats/get-user-watched-countries.ts b/apps/backend/src/domain/services/user-stats/get-user-watched-countries.ts index 8414dff8..d3d1b984 100644 --- a/apps/backend/src/domain/services/user-stats/get-user-watched-countries.ts +++ b/apps/backend/src/domain/services/user-stats/get-user-watched-countries.ts @@ -1,7 +1,7 @@ import type { FastifyRedis } from '@fastify/redis' import type { Language } from '@plotwist_app/tmdb' -import type { StatsPeriod } from '@/infra/http/schemas/common' import { selectAllUserItemsByStatus } from '@/infra/db/repositories/user-item-repository' +import type { StatsPeriod } from '@/infra/http/schemas/common' import { getTMDBMovieService } from '../tmdb/get-tmdb-movie' import { getTMDBTvSeriesService } from '../tmdb/get-tmdb-tv-series' import { processInBatches } from './batch-utils' @@ -22,7 +22,12 @@ export async function getUserWatchedCountriesService({ dateRange, period = 'all', }: GetUserWatchedCountriesServiceInput) { - const cacheKey = getUserStatsCacheKey(userId, 'watched-countries', language, period) + const cacheKey = getUserStatsCacheKey( + userId, + 'watched-countries', + language, + period + ) return getCachedStats(redis, cacheKey, async () => { const watchedItems = await selectAllUserItemsByStatus({ diff --git a/apps/backend/src/infra/adapters/logger.ts b/apps/backend/src/infra/adapters/logger.ts index 9eae4fce..a02b4a29 100644 --- a/apps/backend/src/infra/adapters/logger.ts +++ b/apps/backend/src/infra/adapters/logger.ts @@ -15,7 +15,11 @@ type Level = keyof typeof SEVERITY function flattenValue(key: string, value: unknown, out: OtelAttrs) { if (value === undefined || value === null) return - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { out[key] = value } else if (value instanceof Error) { out[`${key}.type`] = value.name @@ -52,7 +56,11 @@ function parseArgs( const attrs: Attrs = { ...bindings } let message: string - if (typeof first === 'object' && first !== null && !(first instanceof Error)) { + if ( + typeof first === 'object' && + first !== null && + !(first instanceof Error) + ) { Object.assign(attrs, first) message = typeof second === 'string' ? second : '' } else { @@ -79,7 +87,12 @@ function emit(level: Level, message: string, attributes: OtelAttrs) { attributes, }) - const line = JSON.stringify({ level: severityText, msg: message, ...attributes, time: new Date().toISOString() }) + const line = JSON.stringify({ + level: severityText, + msg: message, + ...attributes, + time: new Date().toISOString(), + }) process.stdout.write(`${line}\n`) } @@ -110,7 +123,8 @@ function createLogger(bindings: Attrs = {}): Logger { info: log('info'), warn: log('warn'), error: log('error'), - child: (childBindings: Attrs) => createLogger({ ...bindings, ...childBindings }), + child: (childBindings: Attrs) => + createLogger({ ...bindings, ...childBindings }), } } diff --git a/apps/backend/src/infra/http/controllers/user-items-controller.ts b/apps/backend/src/infra/http/controllers/user-items-controller.ts index 9e20a29b..8d107e43 100644 --- a/apps/backend/src/infra/http/controllers/user-items-controller.ts +++ b/apps/backend/src/infra/http/controllers/user-items-controller.ts @@ -13,12 +13,12 @@ import { getUserItemsCountService } from '@/domain/services/user-items/get-user- import { reorderUserItemsService } from '@/domain/services/user-items/reorder-user-items' import { upsertUserItemService } from '@/domain/services/user-items/upsert-user-item' import { invalidateUserStatsCache } from '@/domain/services/user-stats/cache-utils' +import { logger } from '@/infra/adapters/logger' import { createWatchEntry, deleteWatchEntriesByUserItemId, getWatchEntriesByUserItemId, } from '@/infra/db/repositories/user-watch-entries-repository' -import { logger } from '@/infra/adapters/logger' import { deleteUserItemParamsSchema, getAllUserItemsQuerySchema, diff --git a/apps/backend/src/infra/http/controllers/user-stats.ts b/apps/backend/src/infra/http/controllers/user-stats.ts index a615d1fb..ec53c2b7 100644 --- a/apps/backend/src/infra/http/controllers/user-stats.ts +++ b/apps/backend/src/infra/http/controllers/user-stats.ts @@ -5,11 +5,11 @@ import { getUserItemsStatusService } from '@/domain/services/user-stats/get-user import { getUserMostWatchedSeriesService } from '@/domain/services/user-stats/get-user-most-watched-series' import { getUserReviewsCountService } from '@/domain/services/user-stats/get-user-reviews-count' import { getUserStatsService } from '@/domain/services/user-stats/get-user-stats' +import { getUserStatsTimelineService } from '@/domain/services/user-stats/get-user-stats-timeline' import { getUserTotalHoursService } from '@/domain/services/user-stats/get-user-total-hours' import { getUserWatchedCastService } from '@/domain/services/user-stats/get-user-watched-cast' import { getUserWatchedCountriesService } from '@/domain/services/user-stats/get-user-watched-countries' import { getUserWatchedGenresService } from '@/domain/services/user-stats/get-user-watched-genres' -import { getUserStatsTimelineService } from '@/domain/services/user-stats/get-user-stats-timeline' import { languageWithLimitAndPeriodQuerySchema, languageWithPeriodQuerySchema, diff --git a/apps/backend/src/infra/http/middlewares/verify-jwt.ts b/apps/backend/src/infra/http/middlewares/verify-jwt.ts index ab46590b..2871ff77 100644 --- a/apps/backend/src/infra/http/middlewares/verify-jwt.ts +++ b/apps/backend/src/infra/http/middlewares/verify-jwt.ts @@ -1,5 +1,5 @@ -import type { FastifyReply, FastifyRequest } from 'fastify' import { trace } from '@opentelemetry/api' +import type { FastifyReply, FastifyRequest } from 'fastify' import { logger } from '@/infra/adapters/logger' export async function verifyJwt(request: FastifyRequest, reply: FastifyReply) { diff --git a/apps/backend/src/infra/http/middlewares/verify-optional-jwt.ts b/apps/backend/src/infra/http/middlewares/verify-optional-jwt.ts index fc405876..e4bf1d1f 100644 --- a/apps/backend/src/infra/http/middlewares/verify-optional-jwt.ts +++ b/apps/backend/src/infra/http/middlewares/verify-optional-jwt.ts @@ -1,5 +1,5 @@ -import type { FastifyRequest } from 'fastify' import { trace } from '@opentelemetry/api' +import type { FastifyRequest } from 'fastify' export async function verifyOptionalJwt(request: FastifyRequest) { try { diff --git a/apps/backend/src/infra/http/schemas/common.ts b/apps/backend/src/infra/http/schemas/common.ts index fa6b9920..685e9335 100644 --- a/apps/backend/src/infra/http/schemas/common.ts +++ b/apps/backend/src/infra/http/schemas/common.ts @@ -43,10 +43,7 @@ export const timelineQuerySchema = z.object({ .enum(['en-US', 'es-ES', 'fr-FR', 'de-DE', 'it-IT', 'pt-BR', 'ja-JP']) .optional() .default('en-US'), - cursor: z - .string() - .regex(yearMonthRegex) - .optional(), + cursor: z.string().regex(yearMonthRegex).optional(), pageSize: z.coerce.number().int().min(1).max(10).optional().default(3), }) @@ -86,7 +83,7 @@ export function periodToDateRange(period: StatsPeriod): { const startDate = new Date(now.getFullYear(), 0, 1) return { startDate, endDate: undefined } } - case 'all': + default: return { startDate: undefined, endDate: undefined } } diff --git a/apps/backend/src/infra/telemetry/otel.ts b/apps/backend/src/infra/telemetry/otel.ts index 85e6e3d4..138771af 100644 --- a/apps/backend/src/infra/telemetry/otel.ts +++ b/apps/backend/src/infra/telemetry/otel.ts @@ -7,7 +7,10 @@ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' import { HostMetrics } from '@opentelemetry/host-metrics' import { HttpInstrumentation } from '@opentelemetry/instrumentation-http' import { resourceFromAttributes } from '@opentelemetry/resources' -import { BatchLogRecordProcessor, LoggerProvider } from '@opentelemetry/sdk-logs' +import { + BatchLogRecordProcessor, + LoggerProvider, +} from '@opentelemetry/sdk-logs' import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' import { NodeSDK } from '@opentelemetry/sdk-node' import { diff --git a/apps/web/src/app/[lang]/page.tsx b/apps/web/src/app/[lang]/page.tsx index ed116aa3..7d6e8e43 100644 --- a/apps/web/src/app/[lang]/page.tsx +++ b/apps/web/src/app/[lang]/page.tsx @@ -6,7 +6,6 @@ import type { PageProps } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' import { buildLanguageAlternates } from '@/utils/seo' import { APP_URL } from '../../../constants' -import { SUPPORTED_LANGUAGES } from '../../../languages' import { AppDownload } from './_components/app-download' import { Features } from './_components/features' import { Hero } from './_components/hero' diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 149da46d..efbb62a7 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -4,10 +4,7 @@ import type { Metadata, Viewport } from 'next' import { Space_Grotesk as SpaceGrotesk } from 'next/font/google' import { ViewTransitions } from 'next-view-transitions' import { GTag } from '@/components/gtag' -import { - OrganizationJsonLd, - WebsiteJsonLd, -} from '@/components/structured-data' +import { OrganizationJsonLd, WebsiteJsonLd } from '@/components/structured-data' const spaceGrotesk = SpaceGrotesk({ subsets: ['latin'], preload: true }) diff --git a/apps/web/src/components/structured-data.tsx b/apps/web/src/components/structured-data.tsx index 731665bf..807dcb6f 100644 --- a/apps/web/src/components/structured-data.tsx +++ b/apps/web/src/components/structured-data.tsx @@ -3,12 +3,7 @@ type JsonLdProps = { } export function JsonLd({ data }: JsonLdProps) { - return ( - } export function WebsiteJsonLd() { diff --git a/apps/web/src/utils/seo.ts b/apps/web/src/utils/seo.ts index f7c3548e..e93d8bca 100644 --- a/apps/web/src/utils/seo.ts +++ b/apps/web/src/utils/seo.ts @@ -19,7 +19,8 @@ export function buildLanguageAlternates( languages: SUPPORTED_LANGUAGES.reduce( (acc, language) => { if (language.enabled) { - acc[language.hreflang] = `${APP_URL}/${language.value}${normalizedPath}` + acc[language.hreflang] = + `${APP_URL}/${language.value}${normalizedPath}` } return acc }, From 6b930e1dfd6844cba422df8722f673957f40057a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Alves?= Date: Sun, 1 Mar 2026 18:01:32 -0300 Subject: [PATCH 4/5] test(user-import): implement sorting for import results in user import tests to ensure consistent comparison --- .../get-detailed-user-import-by-id.spec.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/domain/services/imports/get-detailed-user-import-by-id.spec.ts b/apps/backend/src/domain/services/imports/get-detailed-user-import-by-id.spec.ts index 9cd7d85f..f652b3a3 100644 --- a/apps/backend/src/domain/services/imports/get-detailed-user-import-by-id.spec.ts +++ b/apps/backend/src/domain/services/imports/get-detailed-user-import-by-id.spec.ts @@ -4,6 +4,20 @@ import { makeUser } from '@/test/factories/make-user' import { makeUserImport } from '@/test/factories/make-user-import' import { getDetailedUserImportById } from './get-detailed-user-import-by-id' +function sortImportResult( + r: T +): T { + return { + ...r, + ...(r.series && { + series: [...r.series].sort((a, b) => a.id.localeCompare(b.id)), + }), + ...(r.movies && { + movies: [...r.movies].sort((a, b) => a.id.localeCompare(b.id)), + }), + } +} + describe('get user import', () => { it('should be able to get user import by id', async () => { const { id: userId } = await makeUser({}) @@ -17,7 +31,7 @@ describe('get user import', () => { const sut = await getDetailedUserImportById(result.id) - expect(sut).toEqual(result) + expect(sortImportResult(sut)).toEqual(sortImportResult(result)) }) it('should be able to get user import by id when movies are empty', async () => { @@ -33,7 +47,7 @@ describe('get user import', () => { const sut = await getDetailedUserImportById(result.id) - expect(sut).toEqual(result) + expect(sortImportResult(sut)).toEqual(sortImportResult(result)) }) it('should be able to get user import by id when series are empty', async () => { @@ -46,6 +60,6 @@ describe('get user import', () => { const sut = await getDetailedUserImportById(result.id) - expect(sut).toEqual(result) + expect(sortImportResult(sut)).toEqual(sortImportResult(result)) }) }) From 0e32b19e757e7b25bef95f44df5624153dd5994e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Alves?= Date: Sun, 1 Mar 2026 18:09:10 -0300 Subject: [PATCH 5/5] refactor(user-import): improve type definition for sortImportResult function in user import tests for enhanced clarity --- .../services/imports/get-detailed-user-import-by-id.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/domain/services/imports/get-detailed-user-import-by-id.spec.ts b/apps/backend/src/domain/services/imports/get-detailed-user-import-by-id.spec.ts index f652b3a3..bd1c012b 100644 --- a/apps/backend/src/domain/services/imports/get-detailed-user-import-by-id.spec.ts +++ b/apps/backend/src/domain/services/imports/get-detailed-user-import-by-id.spec.ts @@ -4,9 +4,9 @@ import { makeUser } from '@/test/factories/make-user' import { makeUserImport } from '@/test/factories/make-user-import' import { getDetailedUserImportById } from './get-detailed-user-import-by-id' -function sortImportResult( - r: T -): T { +function sortImportResult< + T extends { series?: { id: string }[]; movies?: { id: string }[] }, +>(r: T): T { return { ...r, ...(r.series && {