@kaikybrofc/omnizap-system 2.2.4 → 2.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.env.example +5 -0
  2. package/.prettierrc +16 -0
  3. package/README.md +13 -13
  4. package/app/modules/stickerPackModule/autoPackCollectorService.js +63 -8
  5. package/app/modules/stickerPackModule/catalogHandlers/catalogAdminHttp.js +68 -0
  6. package/app/modules/stickerPackModule/catalogHandlers/catalogAuthHttp.js +34 -0
  7. package/app/modules/stickerPackModule/catalogHandlers/catalogPublicHttp.js +179 -0
  8. package/app/modules/stickerPackModule/catalogHandlers/catalogUploadHttp.js +92 -0
  9. package/app/modules/stickerPackModule/catalogRouter.js +79 -0
  10. package/app/modules/stickerPackModule/domainEventOutboxRepository.js +243 -0
  11. package/app/modules/stickerPackModule/domainEvents.js +61 -0
  12. package/app/modules/stickerPackModule/stickerAssetClassificationRepository.js +21 -0
  13. package/app/modules/stickerPackModule/stickerAssetRepository.js +19 -0
  14. package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +55 -15
  15. package/app/modules/stickerPackModule/stickerDedicatedTaskWorkerRuntime.js +238 -0
  16. package/app/modules/stickerPackModule/stickerDomainEventBus.js +71 -0
  17. package/app/modules/stickerPackModule/stickerDomainEventConsumerRuntime.js +198 -0
  18. package/app/modules/stickerPackModule/stickerObjectStorageService.js +285 -0
  19. package/app/modules/stickerPackModule/stickerPackCatalogHttp.js +570 -536
  20. package/app/modules/stickerPackModule/stickerPackEngagementRepository.js +44 -0
  21. package/app/modules/stickerPackModule/stickerPackItemRepository.js +18 -0
  22. package/app/modules/stickerPackModule/stickerPackRepository.js +51 -0
  23. package/app/modules/stickerPackModule/stickerPackScoreSnapshotRepository.js +191 -0
  24. package/app/modules/stickerPackModule/stickerPackScoreSnapshotRuntime.js +301 -0
  25. package/app/modules/stickerPackModule/stickerStorageService.js +111 -10
  26. package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +21 -0
  27. package/app/modules/stickerPackModule/stickerWorkerTaskQueueRepository.js +59 -7
  28. package/app/observability/metrics.js +169 -0
  29. package/app/services/featureFlagService.js +137 -0
  30. package/database/index.js +5 -0
  31. package/database/migrations/20260228_0022_sticker_scale_indexes.sql +16 -0
  32. package/database/migrations/20260228_0023_sticker_pack_score_snapshot.sql +25 -0
  33. package/database/migrations/20260228_0024_domain_event_outbox.sql +42 -0
  34. package/database/migrations/20260228_0025_sticker_worker_task_idempotency_dlq.sql +23 -0
  35. package/database/migrations/20260228_0026_feature_flags.sql +21 -0
  36. package/ecosystem.prod.config.cjs +70 -9
  37. package/index.js +26 -0
  38. package/kaikybrofc-omnizap-system-2.2.6.tgz +0 -0
  39. package/observability/sticker-catalog-slo.md +83 -0
  40. package/observability/sticker-scale-hardening-rollout.md +128 -0
  41. package/package.json +7 -35
  42. package/public/assets/images/brand-icon-192.png +0 -0
  43. package/public/assets/images/brand-logo-128.webp +0 -0
  44. package/public/assets/images/hero-banner-1280.avif +0 -0
  45. package/public/assets/images/hero-banner-1280.jpg +0 -0
  46. package/public/assets/images/hero-banner-1280.webp +0 -0
  47. package/public/assets/images/hero-banner-720.avif +0 -0
  48. package/public/assets/images/hero-banner-720.webp +0 -0
  49. package/public/index.html +120 -18
  50. package/public/js/apps/homeApp.js +469 -353
  51. package/public/robots.txt +9 -0
  52. package/public/sitemap.xml +28 -0
  53. package/scripts/sticker-catalog-loadtest.mjs +208 -0
  54. package/scripts/sticker-worker-task.mjs +122 -0
  55. package/observability/mysql-exporter.cnf +0 -5
@@ -4,6 +4,7 @@ import { createHash, randomUUID } from 'node:crypto';
4
4
 
5
5
  import logger from '../../utils/logger/loggerModule.js';
6
6
  import { downloadMediaMessage, extractMediaDetails } from '../../config/baileysConfig.js';
7
+ import { isFeatureEnabled } from '../../services/featureFlagService.js';
7
8
  import {
8
9
  createStickerAsset,
9
10
  findLatestStickerAssetByOwner,
@@ -14,6 +15,12 @@ import {
14
15
  import { ensureStickerAssetClassified } from './stickerClassificationService.js';
15
16
  import { STICKER_PACK_ERROR_CODES, StickerPackError } from './stickerPackErrors.js';
16
17
  import { normalizeOwnerJid } from './stickerPackUtils.js';
18
+ import {
19
+ getStickerObjectStorageUrl,
20
+ isStickerObjectStorageEnabled,
21
+ readStickerFromObjectStorage,
22
+ uploadStickerToObjectStorage,
23
+ } from './stickerObjectStorageService.js';
17
24
 
18
25
  /**
19
26
  * Camada de storage local para assets de figurinha do sistema de packs.
@@ -23,9 +30,29 @@ const TEMP_ROOT = path.join(process.cwd(), 'temp', 'sticker-pack-assets');
23
30
  const DEFAULT_MAX_STICKER_BYTES = 2 * 1024 * 1024;
24
31
  const MAX_STICKER_BYTES = Math.max(64 * 1024, Number(process.env.STICKER_PACK_MAX_STICKER_BYTES) || DEFAULT_MAX_STICKER_BYTES);
25
32
  const LAST_STICKER_TTL_MS = Math.max(60_000, Number(process.env.STICKER_PACK_LAST_CACHE_TTL_MS) || 6 * 60 * 60 * 1000);
33
+ const OBJECT_STORAGE_UPLOAD_TIMEOUT_MS = Math.max(
34
+ 1_000,
35
+ Number(process.env.STICKER_OBJECT_STORAGE_UPLOAD_TIMEOUT_MS) || 10_000,
36
+ );
26
37
 
27
38
  const lastStickerCache = new Map();
28
39
 
40
+ const withTimeout = (promise, ms) =>
41
+ Promise.race([
42
+ Promise.resolve(promise),
43
+ new Promise((_, reject) => {
44
+ setTimeout(() => {
45
+ reject(new Error(`timeout_after_${ms}ms`));
46
+ }, ms);
47
+ }),
48
+ ]);
49
+
50
+ const isObjectStorageDeliveryEnabled = async (subjectKey = '') =>
51
+ isFeatureEnabled('enable_object_storage_delivery', {
52
+ fallback: false,
53
+ subjectKey: subjectKey || 'object_storage_delivery',
54
+ });
55
+
29
56
  /**
30
57
  * Converte JID para um token seguro no caminho de disco.
31
58
  *
@@ -182,6 +209,31 @@ const ensureStorageForAsset = async ({ ownerJid, sha256, buffer }) => {
182
209
  return targetPath;
183
210
  };
184
211
 
212
+ const uploadExternalStorageBestEffort = async ({ ownerJid, sha256, buffer, mimetype = 'image/webp', assetId = null }) => {
213
+ if (!isStickerObjectStorageEnabled()) return;
214
+ const canUpload = await isObjectStorageDeliveryEnabled(normalizeOwnerJid(ownerJid) || sha256);
215
+ if (!canUpload) return;
216
+ try {
217
+ await withTimeout(
218
+ uploadStickerToObjectStorage({
219
+ ownerJid,
220
+ sha256,
221
+ buffer,
222
+ mimetype,
223
+ }),
224
+ OBJECT_STORAGE_UPLOAD_TIMEOUT_MS,
225
+ );
226
+ } catch (error) {
227
+ logger.warn('Falha no upload best-effort para object storage.', {
228
+ action: 'sticker_object_storage_upload_best_effort_failed',
229
+ owner_jid: ownerJid || null,
230
+ asset_id: assetId || null,
231
+ sha256: sha256 || null,
232
+ error: error?.message,
233
+ });
234
+ }
235
+ };
236
+
185
237
  /**
186
238
  * Extrai detalhes de mídia sticker de uma mensagem.
187
239
  *
@@ -253,6 +305,13 @@ async function persistStickerAssetBuffer({ ownerJid, buffer, mimetype = 'image/w
253
305
  error: error?.message,
254
306
  });
255
307
  });
308
+ await uploadExternalStorageBestEffort({
309
+ ownerJid: normalizedOwner,
310
+ sha256,
311
+ buffer,
312
+ mimetype,
313
+ assetId: repaired.id,
314
+ });
256
315
  return repaired;
257
316
  }
258
317
 
@@ -265,6 +324,13 @@ async function persistStickerAssetBuffer({ ownerJid, buffer, mimetype = 'image/w
265
324
  error: error?.message,
266
325
  });
267
326
  });
327
+ await uploadExternalStorageBestEffort({
328
+ ownerJid: normalizedOwner,
329
+ sha256,
330
+ buffer,
331
+ mimetype,
332
+ assetId: existing.id,
333
+ });
268
334
  return existing;
269
335
  }
270
336
 
@@ -293,6 +359,13 @@ async function persistStickerAssetBuffer({ ownerJid, buffer, mimetype = 'image/w
293
359
  error: error?.message,
294
360
  });
295
361
  });
362
+ await uploadExternalStorageBestEffort({
363
+ ownerJid: normalizedOwner,
364
+ sha256,
365
+ buffer,
366
+ mimetype,
367
+ assetId: created.id,
368
+ });
296
369
  return created;
297
370
  } catch (error) {
298
371
  if (error?.code === 'ER_DUP_ENTRY') {
@@ -479,19 +552,46 @@ export async function resolveStickerAssetForCommand({
479
552
  * @throws {StickerPackError} Quando o arquivo não puder ser lido.
480
553
  */
481
554
  export async function readStickerAssetBuffer(asset) {
482
- if (!asset?.storage_path) {
483
- throw new StickerPackError(STICKER_PACK_ERROR_CODES.STORAGE_ERROR, 'Caminho do sticker não encontrado no storage.');
555
+ if (asset?.storage_path) {
556
+ try {
557
+ return await fs.readFile(asset.storage_path);
558
+ } catch (error) {
559
+ const externalBuffer = await readStickerFromObjectStorage(asset).catch(() => null);
560
+ if (Buffer.isBuffer(externalBuffer) && externalBuffer.length) {
561
+ return externalBuffer;
562
+ }
563
+ throw new StickerPackError(
564
+ STICKER_PACK_ERROR_CODES.STORAGE_ERROR,
565
+ `Não foi possível ler a figurinha em disco (${asset.storage_path}).`,
566
+ error,
567
+ );
568
+ }
484
569
  }
485
570
 
486
- try {
487
- return await fs.readFile(asset.storage_path);
488
- } catch (error) {
489
- throw new StickerPackError(
490
- STICKER_PACK_ERROR_CODES.STORAGE_ERROR,
491
- `Não foi possível ler a figurinha em disco (${asset.storage_path}).`,
492
- error,
493
- );
571
+ const externalBuffer = await readStickerFromObjectStorage(asset).catch(() => null);
572
+ if (Buffer.isBuffer(externalBuffer) && externalBuffer.length) {
573
+ return externalBuffer;
494
574
  }
575
+
576
+ throw new StickerPackError(STICKER_PACK_ERROR_CODES.STORAGE_ERROR, 'Caminho do sticker não encontrado no storage.');
577
+ }
578
+
579
+ export async function getStickerAssetExternalUrl(
580
+ asset,
581
+ {
582
+ secure = true,
583
+ expiresInSeconds = 300,
584
+ } = {},
585
+ ) {
586
+ if (!asset) return null;
587
+ const canUseExternalDelivery = await isObjectStorageDeliveryEnabled(
588
+ normalizeOwnerJid(asset?.owner_jid || '') || String(asset?.id || asset?.sha256 || ''),
589
+ );
590
+ if (!canUseExternalDelivery) return null;
591
+ return getStickerObjectStorageUrl(asset, {
592
+ secure,
593
+ expiresInSeconds,
594
+ });
495
595
  }
496
596
 
497
597
  /**
@@ -503,5 +603,6 @@ export function getStickerStorageConfig() {
503
603
  return {
504
604
  storageRoot: STORAGE_ROOT,
505
605
  maxStickerBytes: MAX_STICKER_BYTES,
606
+ objectStorageEnabled: isStickerObjectStorageEnabled(),
506
607
  };
507
608
  }
@@ -1,5 +1,6 @@
1
1
  import logger from '../../utils/logger/loggerModule.js';
2
2
  import { setQueueDepth } from '../../observability/metrics.js';
3
+ import { isFeatureEnabled } from '../../services/featureFlagService.js';
3
4
  import { runStickerClassificationCycle } from './stickerClassificationBackgroundRuntime.js';
4
5
  import { runStickerAutoPackByTagsCycle } from './stickerAutoPackByTagsRuntime.js';
5
6
  import {
@@ -24,6 +25,8 @@ const STARTUP_DELAY_MS = Math.max(1_000, Number(process.env.STICKER_WORKER_PIPEL
24
25
  const SCHEDULER_INTERVAL_MS = Math.max(2_000, Number(process.env.STICKER_WORKER_PIPELINE_SCHEDULER_INTERVAL_MS) || 15_000);
25
26
  const POLLER_INTERVAL_MS = Math.max(1_000, Number(process.env.STICKER_WORKER_PIPELINE_POLLER_INTERVAL_MS) || 4_000);
26
27
  const WORKER_RETRY_DELAY_SECONDS = Math.max(5, Math.min(3600, Number(process.env.STICKER_WORKER_PIPELINE_RETRY_DELAY_SECONDS) || 45));
28
+ const INLINE_POLLER_ENABLED = parseEnvBool(process.env.STICKER_WORKER_PIPELINE_INLINE_POLLER_ENABLED, true);
29
+ const INLINE_POLLER_FORCE_ENABLED = parseEnvBool(process.env.STICKER_WORKER_PIPELINE_INLINE_POLLER_FORCE_ENABLED, false);
27
30
 
28
31
  const TASK_CADENCE_MS = {
29
32
  classification_cycle: Math.max(10_000, Number(process.env.STICKER_WORKER_CLASSIFICATION_CADENCE_MS) || 120_000),
@@ -36,6 +39,10 @@ const TASK_PRIORITY = {
36
39
  curation_cycle: 55,
37
40
  rebuild_cycle: 50,
38
41
  };
42
+ const PIPELINE_PROCESS_COHORT_KEY =
43
+ String(process.env.STICKER_WORKER_PIPELINE_COHORT_KEY || process.env.HOSTNAME || process.pid)
44
+ .trim()
45
+ || 'pipeline';
39
46
 
40
47
  let startupHandle = null;
41
48
  let schedulerHandle = null;
@@ -62,6 +69,16 @@ const refreshQueueDepthMetrics = async () => {
62
69
  setQueueDepth('sticker_worker_tasks_failed', failed);
63
70
  };
64
71
 
72
+ const canRunInlinePoller = async () => {
73
+ if (!INLINE_POLLER_ENABLED) return false;
74
+ if (INLINE_POLLER_FORCE_ENABLED) return true;
75
+ const dedicatedWorkersEnabled = await isFeatureEnabled('enable_worker_dedicated_processes', {
76
+ fallback: false,
77
+ subjectKey: `pipeline:inline_poller:${PIPELINE_PROCESS_COHORT_KEY}`,
78
+ });
79
+ return !dedicatedWorkersEnabled;
80
+ };
81
+
65
82
  const scheduleTaskIfNeeded = async (taskType) => {
66
83
  if (!taskQueueAvailable) return;
67
84
  const cadence = TASK_CADENCE_MS[taskType];
@@ -81,6 +98,7 @@ const scheduleTaskIfNeeded = async (taskType) => {
81
98
  taskType,
82
99
  payload: { scheduled_by: 'sticker_worker_pipeline' },
83
100
  priority: TASK_PRIORITY[taskType] || 50,
101
+ idempotencyKey: `pipeline:${taskType}:${Math.floor(now / cadence)}`,
84
102
  });
85
103
  nextScheduleByTask.set(taskType, now + cadence);
86
104
  };
@@ -150,6 +168,8 @@ const pollerTick = async () => {
150
168
  runningPoll = true;
151
169
 
152
170
  try {
171
+ if (!(await canRunInlinePoller())) return;
172
+
153
173
  let progressed = false;
154
174
 
155
175
  progressed = (await processSingleTaskType('classification_cycle')) || progressed;
@@ -191,6 +211,7 @@ export const startStickerWorkerPipeline = () => {
191
211
  startup_delay_ms: STARTUP_DELAY_MS,
192
212
  scheduler_interval_ms: SCHEDULER_INTERVAL_MS,
193
213
  poller_interval_ms: POLLER_INTERVAL_MS,
214
+ inline_poller_enabled: INLINE_POLLER_ENABLED,
194
215
  cadence_ms: TASK_CADENCE_MS,
195
216
  });
196
217
 
@@ -20,6 +20,19 @@ const clampInt = (value, fallback, min, max) => {
20
20
  return Math.max(min, Math.min(max, Math.floor(numeric)));
21
21
  };
22
22
 
23
+ const CLAIM_LOCK_TIMEOUT_SECONDS = clampInt(
24
+ process.env.STICKER_WORKER_TASK_LOCK_TIMEOUT_SECONDS,
25
+ 15 * 60,
26
+ 30,
27
+ 24 * 60 * 60,
28
+ );
29
+
30
+ const normalizeIdempotencyKey = (value) =>
31
+ String(value || '')
32
+ .trim()
33
+ .replace(/[^a-zA-Z0-9_:-]/g, '')
34
+ .slice(0, 180);
35
+
23
36
  const parseJson = (value, fallback = null) => {
24
37
  if (value === null || value === undefined) return fallback;
25
38
  if (typeof value === 'object') return value;
@@ -48,6 +61,7 @@ const normalizeRow = (row) => {
48
61
  id: Number(row.id),
49
62
  task_type: row.task_type,
50
63
  payload: parseJson(row.payload, {}),
64
+ idempotency_key: row.idempotency_key || null,
51
65
  priority: Number(row.priority || 0),
52
66
  scheduled_at: row.scheduled_at || null,
53
67
  status: row.status,
@@ -63,7 +77,7 @@ const normalizeRow = (row) => {
63
77
  };
64
78
 
65
79
  export async function enqueueWorkerTask(
66
- { taskType, payload = {}, priority = 50, scheduledAt = null, maxAttempts = 5 },
80
+ { taskType, payload = {}, priority = 50, scheduledAt = null, maxAttempts = 5, idempotencyKey = '' },
67
81
  connection = null,
68
82
  ) {
69
83
  const normalizedTaskType = normalizeTaskType(taskType);
@@ -73,12 +87,26 @@ export async function enqueueWorkerTask(
73
87
  const safeMaxAttempts = clampInt(maxAttempts, 5, 1, 20);
74
88
  const safeScheduledAt = scheduledAt ? new Date(scheduledAt) : null;
75
89
  const scheduledValue = safeScheduledAt && Number.isFinite(safeScheduledAt.valueOf()) ? safeScheduledAt : null;
90
+ const normalizedIdempotencyKey = normalizeIdempotencyKey(idempotencyKey) || null;
76
91
 
77
92
  await executeQuery(
78
93
  `INSERT INTO ${TABLES.STICKER_WORKER_TASK_QUEUE}
79
- (task_type, payload, priority, scheduled_at, status, attempts, max_attempts)
80
- VALUES (?, ?, ?, COALESCE(?, UTC_TIMESTAMP()), 'pending', 0, ?)`,
81
- [normalizedTaskType, JSON.stringify(payload || {}), safePriority, scheduledValue, safeMaxAttempts],
94
+ (task_type, idempotency_key, payload, priority, scheduled_at, status, attempts, max_attempts)
95
+ VALUES (?, ?, ?, ?, COALESCE(?, UTC_TIMESTAMP()), 'pending', 0, ?)
96
+ ON DUPLICATE KEY UPDATE
97
+ payload = IF(status IN ('pending', 'failed'), VALUES(payload), payload),
98
+ priority = GREATEST(priority, VALUES(priority)),
99
+ scheduled_at = LEAST(scheduled_at, VALUES(scheduled_at)),
100
+ status = IF(status = 'failed' AND attempts < max_attempts, 'pending', status),
101
+ updated_at = UTC_TIMESTAMP()`,
102
+ [
103
+ normalizedTaskType,
104
+ normalizedIdempotencyKey,
105
+ JSON.stringify(payload || {}),
106
+ safePriority,
107
+ scheduledValue,
108
+ safeMaxAttempts,
109
+ ],
82
110
  connection,
83
111
  );
84
112
  return true;
@@ -92,7 +120,10 @@ export async function hasPendingWorkerTask(taskType, connection = null) {
92
120
  `SELECT id
93
121
  FROM ${TABLES.STICKER_WORKER_TASK_QUEUE}
94
122
  WHERE task_type = ?
95
- AND status IN ('pending', 'processing')
123
+ AND (
124
+ status IN ('pending', 'processing')
125
+ OR (status = 'failed' AND attempts < max_attempts)
126
+ )
96
127
  LIMIT 1`,
97
128
  [normalizedTaskType],
98
129
  connection,
@@ -106,8 +137,11 @@ export async function claimWorkerTask({ taskType, allowRetryFailed = true } = {}
106
137
 
107
138
  const workerToken = randomUUID();
108
139
  const statusClause = allowRetryFailed
109
- ? "(status = 'pending' OR (status = 'failed' AND attempts < max_attempts))"
110
- : "status = 'pending'";
140
+ ? `(status = 'pending'
141
+ OR (status = 'failed' AND attempts < max_attempts)
142
+ OR (status = 'processing' AND locked_at <= (UTC_TIMESTAMP() - INTERVAL ${CLAIM_LOCK_TIMEOUT_SECONDS} SECOND)))`
143
+ : `(status = 'pending'
144
+ OR (status = 'processing' AND locked_at <= (UTC_TIMESTAMP() - INTERVAL ${CLAIM_LOCK_TIMEOUT_SECONDS} SECOND)))`;
111
145
 
112
146
  await executeQuery(
113
147
  `UPDATE ${TABLES.STICKER_WORKER_TASK_QUEUE}
@@ -187,6 +221,24 @@ export async function failWorkerTask(
187
221
  [message, taskId],
188
222
  connection,
189
223
  );
224
+
225
+ await executeQuery(
226
+ `INSERT INTO ${TABLES.STICKER_WORKER_TASK_DLQ}
227
+ (task_id, task_type, payload, idempotency_key, attempts, max_attempts, priority, last_error)
228
+ SELECT id, task_type, payload, idempotency_key, attempts, max_attempts, priority, last_error
229
+ FROM ${TABLES.STICKER_WORKER_TASK_QUEUE}
230
+ WHERE id = ?
231
+ AND status = 'failed'
232
+ ON DUPLICATE KEY UPDATE
233
+ attempts = VALUES(attempts),
234
+ max_attempts = VALUES(max_attempts),
235
+ priority = VALUES(priority),
236
+ last_error = VALUES(last_error),
237
+ failed_at = CURRENT_TIMESTAMP`,
238
+ [taskId],
239
+ connection,
240
+ ).catch(() => null);
241
+
190
242
  return true;
191
243
  }
192
244
 
@@ -1,6 +1,7 @@
1
1
  import 'dotenv/config';
2
2
 
3
3
  import http from 'node:http';
4
+ import { randomUUID } from 'node:crypto';
4
5
  import { URL } from 'node:url';
5
6
  import client from 'prom-client';
6
7
  import logger from '../utils/logger/loggerModule.js';
@@ -32,6 +33,7 @@ const METRICS_PORT = parseEnvNumber(process.env.METRICS_PORT, 9102);
32
33
  const METRICS_HOST = process.env.METRICS_HOST || '0.0.0.0';
33
34
  const METRICS_PATH = process.env.METRICS_PATH || '/metrics';
34
35
  const METRICS_SERVICE = process.env.METRICS_SERVICE_NAME || process.env.ECOSYSTEM_NAME || 'omnizap';
36
+ const HTTP_SLO_TARGET_MS = Math.max(50, parseEnvNumber(process.env.HTTP_SLO_TARGET_MS, 750));
35
37
 
36
38
  const QUERY_THRESHOLDS_MS = parseThresholds(process.env.DB_QUERY_ALERT_THRESHOLDS, [500, 1000]);
37
39
 
@@ -46,6 +48,55 @@ const normalizeLabel = (value, fallback = 'unknown') => {
46
48
  return String(value);
47
49
  };
48
50
 
51
+ const normalizeRequestId = (value) => {
52
+ const token = String(value || '')
53
+ .trim()
54
+ .replace(/[^a-zA-Z0-9._:-]/g, '')
55
+ .slice(0, 120);
56
+ return token || randomUUID();
57
+ };
58
+
59
+ const normalizeHttpMethod = (method) => {
60
+ const normalized = String(method || '').trim().toUpperCase();
61
+ if (!normalized) return 'UNKNOWN';
62
+ if (normalized.length > 12) return normalized.slice(0, 12);
63
+ return normalized;
64
+ };
65
+
66
+ const toStatusClass = (statusCode) => {
67
+ const numeric = Number(statusCode);
68
+ if (!Number.isFinite(numeric) || numeric < 100) return 'unknown';
69
+ const head = Math.floor(numeric / 100);
70
+ return `${head}xx`;
71
+ };
72
+
73
+ const resolveRouteGroup = ({ pathname, metricsPath, catalogConfig = null } = {}) => {
74
+ if (pathname?.startsWith(metricsPath)) return 'metrics';
75
+ if (pathname === '/sitemap.xml') return 'sitemap';
76
+ if (pathname === '/api/marketplace/stats') return 'marketplace_stats';
77
+
78
+ const apiBasePath = catalogConfig?.apiBasePath || '';
79
+ const webPath = catalogConfig?.webPath || '';
80
+ const dataPublicPath = catalogConfig?.dataPublicPath || '';
81
+ const userProfilePath = catalogConfig?.userProfilePath || '';
82
+
83
+ if (apiBasePath && (pathname === apiBasePath || pathname?.startsWith(`${apiBasePath}/`))) {
84
+ if (pathname === `${apiBasePath}/auth/google/session` || pathname === `${apiBasePath}/me` || pathname === `${apiBasePath}/admin/session`) {
85
+ return 'catalog_api_auth';
86
+ }
87
+ if (pathname === `${apiBasePath}/create` || /\/(manage|finalize|stickers-upload|publish-state)(\/|$)/.test(pathname || '')) {
88
+ return 'catalog_api_upload';
89
+ }
90
+ if (pathname?.startsWith(`${apiBasePath}/admin`)) return 'catalog_api_admin';
91
+ return 'catalog_api_public';
92
+ }
93
+ if (dataPublicPath && (pathname === dataPublicPath || pathname?.startsWith(`${dataPublicPath}/`))) return 'catalog_data_asset';
94
+ if (userProfilePath && (pathname === userProfilePath || pathname === `${userProfilePath}/`)) return 'catalog_user_profile';
95
+ if (webPath && (pathname === webPath || pathname?.startsWith(`${webPath}/`))) return 'catalog_web';
96
+
97
+ return 'other';
98
+ };
99
+
49
100
  const ensureMetrics = () => {
50
101
  if (!METRICS_ENABLED) return null;
51
102
  if (metrics) return metrics;
@@ -108,6 +159,25 @@ const ensureMetrics = () => {
108
159
  labelNames: ['queue'],
109
160
  registers: [registry],
110
161
  }),
162
+ httpRequestsTotal: new client.Counter({
163
+ name: 'omnizap_http_requests_total',
164
+ help: 'Total de requests HTTP por rota lógica',
165
+ labelNames: ['route_group', 'method', 'status_class'],
166
+ registers: [registry],
167
+ }),
168
+ httpRequestDurationMs: new client.Histogram({
169
+ name: 'omnizap_http_request_duration_ms',
170
+ help: 'Latência de requests HTTP em ms por rota lógica',
171
+ buckets: [5, 10, 20, 40, 75, 120, 200, 350, 500, 750, 1000, 2000, 5000, 10000],
172
+ labelNames: ['route_group', 'method', 'status_class'],
173
+ registers: [registry],
174
+ }),
175
+ httpSloViolationTotal: new client.Counter({
176
+ name: 'omnizap_http_slo_violation_total',
177
+ help: 'Total de requests HTTP acima do SLO de latência',
178
+ labelNames: ['route_group', 'method'],
179
+ registers: [registry],
180
+ }),
111
181
  messagesUpsertDurationMs: new client.Histogram({
112
182
  name: 'omnizap_messages_upsert_duration_ms',
113
183
  help: 'Duracao de processamento de messages.upsert em ms',
@@ -296,6 +366,25 @@ const ensureMetrics = () => {
296
366
  help: 'Taxa de packs completos em relacao ao target_size (0-1)',
297
367
  registers: [registry],
298
368
  }),
369
+ stickerClassificationCycleDurationMs: new client.Histogram({
370
+ name: 'omnizap_sticker_classification_cycle_duration_ms',
371
+ help: 'Duração do ciclo de classificação de sticker em ms',
372
+ buckets: [50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000],
373
+ labelNames: ['status'],
374
+ registers: [registry],
375
+ }),
376
+ stickerClassificationCycleTotal: new client.Counter({
377
+ name: 'omnizap_sticker_classification_cycle_total',
378
+ help: 'Total de ciclos de classificação de sticker',
379
+ labelNames: ['status'],
380
+ registers: [registry],
381
+ }),
382
+ stickerClassificationAssetsTotal: new client.Counter({
383
+ name: 'omnizap_sticker_classification_assets_total',
384
+ help: 'Total de assets processados/classificados/falhos por ciclo',
385
+ labelNames: ['outcome'],
386
+ registers: [registry],
387
+ }),
299
388
  };
300
389
 
301
390
  return metrics;
@@ -314,6 +403,10 @@ export const startMetricsServer = () => {
314
403
  if (!METRICS_ENABLED || serverStarted) return;
315
404
  ensureMetrics();
316
405
  server = http.createServer(async (req, res) => {
406
+ const requestStartedAt = Date.now();
407
+ const requestId = normalizeRequestId(req.headers['x-request-id']);
408
+ res.setHeader('X-Request-Id', requestId);
409
+
317
410
  const host = req.headers.host || `${METRICS_HOST}:${METRICS_PORT}`;
318
411
  let parsedUrl;
319
412
  try {
@@ -322,9 +415,32 @@ export const startMetricsServer = () => {
322
415
  parsedUrl = new URL(req.url || '/', 'http://localhost');
323
416
  }
324
417
  const pathname = parsedUrl.pathname;
418
+ let routeGroup = resolveRouteGroup({
419
+ pathname,
420
+ metricsPath: METRICS_PATH,
421
+ catalogConfig: null,
422
+ });
423
+
424
+ res.once('finish', () => {
425
+ recordHttpRequest({
426
+ durationMs: Date.now() - requestStartedAt,
427
+ method: req.method,
428
+ statusCode: res.statusCode,
429
+ routeGroup,
430
+ });
431
+ });
325
432
 
326
433
  try {
327
434
  const stickerCatalogModule = await loadStickerCatalogModule();
435
+ const catalogConfig =
436
+ typeof stickerCatalogModule.getStickerCatalogConfig === 'function'
437
+ ? stickerCatalogModule.getStickerCatalogConfig()
438
+ : null;
439
+ routeGroup = resolveRouteGroup({
440
+ pathname,
441
+ metricsPath: METRICS_PATH,
442
+ catalogConfig,
443
+ });
328
444
  const handledByCatalog = await stickerCatalogModule.maybeHandleStickerCatalogRequest(req, res, {
329
445
  pathname,
330
446
  url: parsedUrl,
@@ -422,6 +538,28 @@ export const setQueueDepth = (queue, depth) => {
422
538
  m.queueDepth.set({ queue: normalizeLabel(queue, 'unknown') }, value);
423
539
  };
424
540
 
541
+ export const recordHttpRequest = ({ durationMs, method, statusCode, routeGroup }) => {
542
+ const m = ensureMetrics();
543
+ if (!m) return;
544
+ const duration = Number(durationMs);
545
+ if (!Number.isFinite(duration) || duration < 0) return;
546
+
547
+ const labels = {
548
+ route_group: normalizeLabel(routeGroup, 'other'),
549
+ method: normalizeHttpMethod(method),
550
+ status_class: toStatusClass(statusCode),
551
+ };
552
+
553
+ m.httpRequestsTotal.inc(labels);
554
+ m.httpRequestDurationMs.observe(labels, duration);
555
+ if (duration >= HTTP_SLO_TARGET_MS) {
556
+ m.httpSloViolationTotal.inc({
557
+ route_group: labels.route_group,
558
+ method: labels.method,
559
+ });
560
+ }
561
+ };
562
+
425
563
  export const setDbInFlight = (value) => {
426
564
  const m = ensureMetrics();
427
565
  if (!m) return;
@@ -732,3 +870,34 @@ export const recordStickerAutoPackCycle = ({
732
870
  m.stickerAutoPackFillRate.set(Math.max(0, Math.min(1, fill)));
733
871
  }
734
872
  };
873
+
874
+ export const recordStickerClassificationCycle = ({
875
+ status = 'ok',
876
+ durationMs,
877
+ processed = 0,
878
+ classified = 0,
879
+ failed = 0,
880
+ } = {}) => {
881
+ const m = ensureMetrics();
882
+ if (!m) return;
883
+
884
+ const cycleStatus = normalizeLabel(status, 'ok').slice(0, 24);
885
+ const duration = Number(durationMs);
886
+ if (Number.isFinite(duration) && duration >= 0) {
887
+ m.stickerClassificationCycleDurationMs.observe({ status: cycleStatus }, duration);
888
+ }
889
+ m.stickerClassificationCycleTotal.inc({ status: cycleStatus });
890
+
891
+ const processedValue = Number(processed);
892
+ if (Number.isFinite(processedValue) && processedValue > 0) {
893
+ m.stickerClassificationAssetsTotal.inc({ outcome: 'processed' }, processedValue);
894
+ }
895
+ const classifiedValue = Number(classified);
896
+ if (Number.isFinite(classifiedValue) && classifiedValue > 0) {
897
+ m.stickerClassificationAssetsTotal.inc({ outcome: 'classified' }, classifiedValue);
898
+ }
899
+ const failedValue = Number(failed);
900
+ if (Number.isFinite(failedValue) && failedValue > 0) {
901
+ m.stickerClassificationAssetsTotal.inc({ outcome: 'failed' }, failedValue);
902
+ }
903
+ };