From 962008868196ca3e1a9583d1fde5e77adb4a4ef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlio=20Guedes?= Date: Thu, 17 Sep 2026 10:20:04 -0300 Subject: [PATCH 1/7] feat(telemetry): backport diagnostics metrics observability to 6.x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports DiagnosticsMetrics and the split traces/metrics/logs telemetry client from master (7.x) to the 6.x branch, following the add-observability-to-6x OpenSpec change (TDD/BDD specs, red-green-refactor tasks): - Bump @vtex/diagnostics-nodejs to 0.1.8-io, add @vtex/diagnostics-semconv and the @opentelemetry/{api,host-metrics,instrumentation,instrumentation-koa} quartet, matching master. - Rewrite the telemetry client into split traces/metrics/logs clients (TelemetryClientSingleton), preserving 6.x's dynamic per-request logger by additionally exposing the raw TelemetryClient via getTelemetryClient(). - Port DiagnosticsMetrics (recordLatency/incrementCounter/setGauge/ runWithBaseAttributes), cluster resource attributes, and Koa/host-metrics auto-instrumentation verbatim from master. - Gate everything behind DIAGNOSTICS_TELEMETRY_ENABLED (default off). - Fix a jest 25 resolver gap (no package.json "exports" map support) that the new @opentelemetry/otlp-exporter-base subpath import exposed, via moduleNameMapper in jest.config.js — this was breaking two pre-existing, unrelated test suites. - Update ExporterOptions usage in the logger client for a real breaking change between diagnostics-nodejs versions (path/protocol/headers removed). 88/88 tests pass; production build compiles clean. One pre-existing, unrelated test suite (axiosTracing.test.ts, via a TestServer.ts TS strictness issue) was already failing before this change and is untouched by it. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 7 + __mocks__/@vtex/diagnostics-semconv.ts | 18 + jest.config.js | 9 + package.json | 7 +- src/constants.test.ts | 33 + src/constants.ts | 29 + src/metrics/DiagnosticsMetrics.test.ts | 754 ++++++++++++++++++ src/metrics/DiagnosticsMetrics.ts | 387 +++++++++ src/service/logger/client.test.ts | 57 ++ src/service/logger/client.ts | 7 +- src/service/metrics/client.ts | 46 ++ .../metrics/instruments/hostMetrics.ts | 43 + src/service/telemetry/client.test.ts | 190 +++++ src/service/telemetry/client.ts | 181 ++++- .../telemetry/resourceAttributes.test.ts | 37 + src/service/telemetry/resourceAttributes.ts | 25 + yarn.lock | 138 +++- 17 files changed, 1903 insertions(+), 65 deletions(-) create mode 100644 __mocks__/@vtex/diagnostics-semconv.ts create mode 100644 src/constants.test.ts create mode 100644 src/metrics/DiagnosticsMetrics.test.ts create mode 100644 src/metrics/DiagnosticsMetrics.ts create mode 100644 src/service/logger/client.test.ts create mode 100644 src/service/metrics/client.ts create mode 100644 src/service/metrics/instruments/hostMetrics.ts create mode 100644 src/service/telemetry/client.test.ts create mode 100644 src/service/telemetry/resourceAttributes.test.ts create mode 100644 src/service/telemetry/resourceAttributes.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8af6538b1..aadc6fbe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Diagnostics metrics observability, backported from the `master` (7.x) line: `DiagnosticsMetrics` + (`recordLatency`, `incrementCounter`, `setGauge`, `runWithBaseAttributes`), a split + traces/metrics/logs telemetry client (`@vtex/diagnostics-nodejs@0.1.8-io`, + `@vtex/diagnostics-semconv`), cluster resource attributes, and automatic Koa + host-metrics + instrumentation. Disabled by default; opt in per app with + `VTEX_DIAGNOSTICS_TELEMETRY_ENABLED=true`. ## [6.52.0] ### Added diff --git a/__mocks__/@vtex/diagnostics-semconv.ts b/__mocks__/@vtex/diagnostics-semconv.ts new file mode 100644 index 000000000..8f4ecece1 --- /dev/null +++ b/__mocks__/@vtex/diagnostics-semconv.ts @@ -0,0 +1,18 @@ +// Mock para @vtex/diagnostics-semconv +const ATTR_VTEX_ACCOUNT_NAME = 'vtex.account.name' +const ATTR_VTEX_IO_WORKSPACE_NAME = 'vtex_io.workspace.name' +const ATTR_VTEX_IO_WORKSPACE_TYPE = 'vtex_io.workspace.type' +const ATTR_VTEX_IO_APP_ID = 'vtex_io.app.id' +const ATTR_VTEX_IO_APP_AUTHOR_TYPE = 'vtex_io.app.author-type' +const ATTR_VTEX_IO_CLUSTER_ID = 'vtex_io.cluster.id' +const ATTR_VTEX_IO_CLUSTER_ROLE = 'vtex_io.cluster.role' + +export { + ATTR_VTEX_ACCOUNT_NAME, + ATTR_VTEX_IO_WORKSPACE_NAME, + ATTR_VTEX_IO_WORKSPACE_TYPE, + ATTR_VTEX_IO_APP_ID, + ATTR_VTEX_IO_APP_AUTHOR_TYPE, + ATTR_VTEX_IO_CLUSTER_ID, + ATTR_VTEX_IO_CLUSTER_ROLE, +} diff --git a/jest.config.js b/jest.config.js index 8ca529dce..54091e955 100644 --- a/jest.config.js +++ b/jest.config.js @@ -18,4 +18,13 @@ module.exports = { }, testRegex: '(.*(test|spec)).tsx?$', testEnvironment: 'node', + // jest 25's bundled resolver predates package.json "exports" map support, so + // conditional-export-only subpaths (no legacy "main"-style file) fail to resolve + // even though Node itself resolves them fine at runtime. Map the ones pulled in + // transitively by @vtex/diagnostics-nodejs's OTLP gRPC exporters directly to their + // build output. + moduleNameMapper: { + '^@opentelemetry/otlp-exporter-base/node-http$': + '/node_modules/@opentelemetry/otlp-exporter-base/build/src/index-node-http.js', + }, } diff --git a/package.json b/package.json index daadb48c8..8def5a5db 100644 --- a/package.json +++ b/package.json @@ -47,9 +47,14 @@ }, "license": "MIT", "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/host-metrics": "0.35.5", + "@opentelemetry/instrumentation": "0.57.2", + "@opentelemetry/instrumentation-koa": "0.47.1", "@types/koa": "^2.11.0", "@types/koa-compose": "^3.2.3", - "@vtex/diagnostics-nodejs": "0.1.0-beta.10", + "@vtex/diagnostics-nodejs": "0.1.8-io", + "@vtex/diagnostics-semconv": "5.5.2", "@vtex/node-error-report": "^0.0.3", "@wry/equality": "^0.1.9", "agentkeepalive": "^4.0.2", diff --git a/src/constants.test.ts b/src/constants.test.ts new file mode 100644 index 000000000..968dd54d3 --- /dev/null +++ b/src/constants.test.ts @@ -0,0 +1,33 @@ +describe('DIAGNOSTICS_TELEMETRY_ENABLED', () => { + const ORIGINAL_ENV = process.env + + beforeEach(() => { + jest.resetModules() + process.env = { ...ORIGINAL_ENV } + }) + + afterAll(() => { + process.env = ORIGINAL_ENV + }) + + it('is disabled when the env var is unset', () => { + delete process.env.VTEX_DIAGNOSTICS_TELEMETRY_ENABLED + const { DIAGNOSTICS_TELEMETRY_ENABLED } = require('./constants') + + expect(DIAGNOSTICS_TELEMETRY_ENABLED).toBe(false) + }) + + it.each(['false', '0', 'no', 'TRUE'])('is disabled for the falsy/invalid value %p', value => { + process.env.VTEX_DIAGNOSTICS_TELEMETRY_ENABLED = value + const { DIAGNOSTICS_TELEMETRY_ENABLED } = require('./constants') + + expect(DIAGNOSTICS_TELEMETRY_ENABLED).toBe(false) + }) + + it('is enabled only for the exact literal "true"', () => { + process.env.VTEX_DIAGNOSTICS_TELEMETRY_ENABLED = 'true' + const { DIAGNOSTICS_TELEMETRY_ENABLED } = require('./constants') + + expect(DIAGNOSTICS_TELEMETRY_ENABLED).toBe(true) + }) +}) diff --git a/src/constants.ts b/src/constants.ts index 3860b0af3..de884c0a6 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,12 @@ +import { + ATTR_VTEX_ACCOUNT_NAME, + ATTR_VTEX_IO_APP_AUTHOR_TYPE, + ATTR_VTEX_IO_APP_ID, + ATTR_VTEX_IO_CLUSTER_ID, + ATTR_VTEX_IO_CLUSTER_ROLE, + ATTR_VTEX_IO_WORKSPACE_NAME, + ATTR_VTEX_IO_WORKSPACE_TYPE, +} from '@vtex/diagnostics-semconv' import { versionToMajor } from './utils/app' // tslint:disable-next-line const pkg = require('../package.json') @@ -7,6 +16,9 @@ export const DEFAULT_WORKSPACE = 'master' export const IS_IO = process.env.VTEX_IO export const PID = process.pid +export const CLUSTER_ID = process.env.VTEX_CLUSTER_ID as string +export const CLUSTER_ROLE = process.env.VTEX_CLUSTER_ROLE as string + export const CACHE_CONTROL_HEADER = 'cache-control' export const SEGMENT_HEADER = 'x-vtex-segment' export const SESSION_HEADER = 'x-vtex-session' @@ -51,6 +63,16 @@ export const MAX_AGE = { export const HTTP_SERVER_PORT = 5050 export const MAX_WORKERS = 4 +export const AttributeKeys = { + VTEX_ACCOUNT_NAME: ATTR_VTEX_ACCOUNT_NAME, + VTEX_IO_APP_AUTHOR_TYPE: ATTR_VTEX_IO_APP_AUTHOR_TYPE, + VTEX_IO_APP_ID: ATTR_VTEX_IO_APP_ID, + VTEX_IO_CLUSTER_ID: ATTR_VTEX_IO_CLUSTER_ID, + VTEX_IO_CLUSTER_ROLE: ATTR_VTEX_IO_CLUSTER_ROLE, + VTEX_IO_WORKSPACE_NAME: ATTR_VTEX_IO_WORKSPACE_NAME, + VTEX_IO_WORKSPACE_TYPE: ATTR_VTEX_IO_WORKSPACE_TYPE, +} + export const LINKED = !!process.env.VTEX_APP_LINK export const REGION = process.env.VTEX_REGION as string export const PUBLIC_ENDPOINT = process.env.VTEX_PUBLIC_ENDPOINT || 'myvtex.com' @@ -74,3 +96,10 @@ export const INSPECT_DEBUGGER_PORT = 5858 export const cancellableMethods = new Set(['GET', 'OPTIONS', 'HEAD']) export const LOG_CLIENT_INIT_TIMEOUT_MS = 5000 +export const METRIC_CLIENT_INIT_TIMEOUT_MS = 5000 + +export const OTEL_EXPORTER_OTLP_ENDPOINT = process.env.OTEL_EXPORTER_OTLP_ENDPOINT as string + +export const DK_APP_ID = (process.env.NODE_VTEX_API_DK_APP_ID as string) || 'apps-team' + +export const DIAGNOSTICS_TELEMETRY_ENABLED = process.env.VTEX_DIAGNOSTICS_TELEMETRY_ENABLED === 'true' diff --git a/src/metrics/DiagnosticsMetrics.test.ts b/src/metrics/DiagnosticsMetrics.test.ts new file mode 100644 index 000000000..c06ab9ede --- /dev/null +++ b/src/metrics/DiagnosticsMetrics.test.ts @@ -0,0 +1,754 @@ +import { Types } from '@vtex/diagnostics-nodejs' +import { context } from '@opentelemetry/api' +import { AsyncHooksContextManager } from '@opentelemetry/context-async-hooks' +import { DiagnosticsMetrics } from './DiagnosticsMetrics' + +// Mock only the external I/O boundary (getMetricClient) +jest.mock('../service/metrics/client', () => ({ + getMetricClient: jest.fn(), +})) + +// Mock constants to control LINKED value +jest.mock('../constants', () => ({ + ...jest.requireActual('../constants'), + LINKED: false, // Default to false, will override in specific tests +})) + +import { getMetricClient } from '../service/metrics/client' + +// Set up OpenTelemetry context manager for async context propagation +const contextManager = new AsyncHooksContextManager() +contextManager.enable() +context.setGlobalContextManager(contextManager) + +describe('DiagnosticsMetrics', () => { + let diagnosticsMetrics: DiagnosticsMetrics + let mockMetricsClient: Types.MetricClient + let recordedHistogramCalls: Array<{ value: number; attributes?: any }> + let recordedCounterCalls: Map> + let recordedGaugeCalls: Map> + + beforeEach(() => { + // Reset call tracking + recordedHistogramCalls = [] + recordedCounterCalls = new Map() + recordedGaugeCalls = new Map() + + // Create a mock client that tracks calls instead of using jest.fn() + mockMetricsClient = { + createHistogram: (name: string, options: any) => ({ + record: (value: number, attributes?: any) => { + recordedHistogramCalls.push({ value, attributes }) + }, + }), + createCounter: (name: string, options: any) => ({ + add: (value: number, attributes?: any) => { + if (!recordedCounterCalls.has(name)) { + recordedCounterCalls.set(name, []) + } + recordedCounterCalls.get(name)!.push({ value, attributes }) + }, + }), + createGauge: (name: string, options: any) => ({ + set: (value: number, attributes?: any) => { + if (!recordedGaugeCalls.has(name)) { + recordedGaugeCalls.set(name, []) + } + recordedGaugeCalls.get(name)!.push({ value, attributes }) + }, + }), + } as any + + // Mock only the external call + ;(getMetricClient as jest.Mock).mockResolvedValue(mockMetricsClient) + + // Create real instance + diagnosticsMetrics = new DiagnosticsMetrics() + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('initialization', () => { + it('should initialize metrics client and create latency histogram in constructor', async () => { + // Wait for initialization to complete + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(getMetricClient).toHaveBeenCalledTimes(1) + + // Verify histogram was created by recording a value + diagnosticsMetrics.recordLatency(100) + expect(recordedHistogramCalls).toHaveLength(1) + expect(recordedHistogramCalls[0].value).toBe(100) + }) + + /** + * @description + * Verifies that DiagnosticsMetrics handles metric client initialization failures gracefully + * without crashing the application. + * + * Test Strategy: + * 1. Configure getMetricClient() mock to reject before instance creation + * - This simulates diagnostics service being unavailable + * - Must be done BEFORE constructor runs since it immediately calls getMetricClient() + * + * 2. Create DiagnosticsMetrics instance + * - Constructor calls initMetricClient() synchronously + * - initMetricClient() starts async initialization (returns immediately) + * - Async code races getMetricClient() vs timeout + * - getMetricClient() rejects due to our mock + * - catch block logs error and sets metricsClient = undefined + * + * 3. Wait for async initialization to complete + * - Constructor returns immediately (can't await in constructor) + * - Need to wait for async promise to settle before checking results + * - 10ms is sufficient for promise rejection and catch block execution + * + * 4. Verify graceful degradation + * - Instance was created successfully (no exception thrown) + * - Error was logged to console (operational visibility) + * - metricsClient remains undefined (all record methods will no-op) + * */ + it('should handle initialization errors gracefully', async () => { + + // Mock the getMetricClient (which is a Jest mock) to return an error + // Using mockRejectedValueOnce to configure the mock to reject with an error the next time it's called + const error = new Error('Initialization failed') + ;(getMetricClient as jest.Mock).mockRejectedValueOnce(error) + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() + + // Create new instance that will fail initialization + const failingMetrics = new DiagnosticsMetrics() + + // Wait for initialization attempt (async operation in constructor) + await new Promise(resolve => setTimeout(resolve, 10)) + + // Verify error was logged (provides operational visibility) + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to initialize metric client:', error) + + consoleErrorSpy.mockRestore() + }) + }) + + describe('recordLatency', () => { + beforeEach(async () => { + // Wait for initialization + await new Promise(resolve => setTimeout(resolve, 10)) + }) + + it('should record latency from hrtime tuple to single shared histogram', () => { + const hrtimeDiff: [number, number] = [1, 500000000] // 1.5 seconds + const attributes = { operation: 'api-call', status: '2xx' } + + diagnosticsMetrics.recordLatency(hrtimeDiff, attributes) + + expect(recordedHistogramCalls).toHaveLength(1) + expect(recordedHistogramCalls[0]).toEqual({ value: 1500, attributes }) + }) + + it('should record latency from milliseconds number to single shared histogram', () => { + const milliseconds = 42.5 + const attributes = { operation: 'db-query', status: 'success' } + + diagnosticsMetrics.recordLatency(milliseconds, attributes) + + expect(recordedHistogramCalls).toHaveLength(1) + expect(recordedHistogramCalls[0]).toEqual({ value: milliseconds, attributes }) + }) + + it('should record latency without attributes', () => { + const milliseconds = 100 + + diagnosticsMetrics.recordLatency(milliseconds) + + expect(recordedHistogramCalls).toHaveLength(1) + expect(recordedHistogramCalls[0]).toEqual({ value: milliseconds, attributes: undefined }) + }) + + it('should use the same histogram for all latency measurements', () => { + diagnosticsMetrics.recordLatency(10, { operation: 'op1' }) + diagnosticsMetrics.recordLatency(20, { operation: 'op2' }) + diagnosticsMetrics.recordLatency(30, { operation: 'op3' }) + + // All recordings go to the same histogram + expect(recordedHistogramCalls).toHaveLength(3) + expect(recordedHistogramCalls[0].value).toBe(10) + expect(recordedHistogramCalls[1].value).toBe(20) + expect(recordedHistogramCalls[2].value).toBe(30) + }) + + it('should warn if not initialized', () => { + const uninitializedMetrics = new DiagnosticsMetrics() + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation() + + // Don't wait for initialization + uninitializedMetrics.recordLatency(100) + + expect(consoleWarnSpy).toHaveBeenCalledWith('DiagnosticsMetrics not initialized. Call initialize() first.') + consoleWarnSpy.mockRestore() + }) + }) + + describe('incrementCounter', () => { + beforeEach(async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + }) + + it('should increment counter with value and attributes', () => { + const attributes = { method: 'GET', status: '2xx' } + + diagnosticsMetrics.incrementCounter('http_requests_total', 1, attributes) + + const calls = recordedCounterCalls.get('http_requests_total') + expect(calls).toHaveLength(1) + expect(calls![0]).toEqual({ value: 1, attributes }) + }) + + it('should increment counter without attributes', () => { + diagnosticsMetrics.incrementCounter('requests', 5) + + const calls = recordedCounterCalls.get('requests') + expect(calls).toHaveLength(1) + expect(calls![0]).toEqual({ value: 5, attributes: undefined }) + }) + + it('should reuse existing counter for same metric name', () => { + diagnosticsMetrics.incrementCounter('requests', 1) + diagnosticsMetrics.incrementCounter('requests', 2) + diagnosticsMetrics.incrementCounter('requests', 3) + + const calls = recordedCounterCalls.get('requests') + expect(calls).toHaveLength(3) + expect(calls![0].value).toBe(1) + expect(calls![1].value).toBe(2) + expect(calls![2].value).toBe(3) + }) + + it('should create separate counters for different metric names', () => { + diagnosticsMetrics.incrementCounter('counter1', 1) + diagnosticsMetrics.incrementCounter('counter2', 2) + + expect(recordedCounterCalls.get('counter1')).toHaveLength(1) + expect(recordedCounterCalls.get('counter2')).toHaveLength(1) + expect(recordedCounterCalls.get('counter1')![0].value).toBe(1) + expect(recordedCounterCalls.get('counter2')![0].value).toBe(2) + }) + + it('should warn if not initialized', () => { + const uninitializedMetrics = new DiagnosticsMetrics() + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation() + + uninitializedMetrics.incrementCounter('test', 1) + + expect(consoleWarnSpy).toHaveBeenCalledWith('DiagnosticsMetrics not initialized. Call initialize() first.') + consoleWarnSpy.mockRestore() + }) + }) + + describe('setGauge', () => { + beforeEach(async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + }) + + it('should set gauge with value and attributes', () => { + const attributes = { cache: 'pages' } + + diagnosticsMetrics.setGauge('cache_items_current', 1024, attributes) + + const calls = recordedGaugeCalls.get('cache_items_current') + expect(calls).toHaveLength(1) + expect(calls![0]).toEqual({ value: 1024, attributes }) + }) + + it('should set gauge without attributes', () => { + diagnosticsMetrics.setGauge('memory_usage', 512) + + const calls = recordedGaugeCalls.get('memory_usage') + expect(calls).toHaveLength(1) + expect(calls![0]).toEqual({ value: 512, attributes: undefined }) + }) + + it('should reuse existing gauge for same metric name', () => { + diagnosticsMetrics.setGauge('gauge1', 10) + diagnosticsMetrics.setGauge('gauge1', 20) + diagnosticsMetrics.setGauge('gauge1', 30) + + const calls = recordedGaugeCalls.get('gauge1') + expect(calls).toHaveLength(3) + expect(calls![0].value).toBe(10) + expect(calls![1].value).toBe(20) + expect(calls![2].value).toBe(30) + }) + + it('should create separate gauges for different metric names', () => { + diagnosticsMetrics.setGauge('gauge1', 100) + diagnosticsMetrics.setGauge('gauge2', 200) + + expect(recordedGaugeCalls.get('gauge1')).toHaveLength(1) + expect(recordedGaugeCalls.get('gauge2')).toHaveLength(1) + expect(recordedGaugeCalls.get('gauge1')![0].value).toBe(100) + expect(recordedGaugeCalls.get('gauge2')![0].value).toBe(200) + }) + + it('should warn if not initialized', () => { + const uninitializedMetrics = new DiagnosticsMetrics() + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation() + + uninitializedMetrics.setGauge('test', 100) + + expect(consoleWarnSpy).toHaveBeenCalledWith('DiagnosticsMetrics not initialized. Call initialize() first.') + consoleWarnSpy.mockRestore() + }) + }) + + describe('Attribute Limiting', () => { + beforeEach(() => { + // Enable LINKED for these tests so warnings are triggered + const constants = require('../constants') + Object.defineProperty(constants, 'LINKED', { + value: true, + writable: true, + configurable: true, + }) + }) + + afterEach(() => { + // Reset LINKED back to false + const constants = require('../constants') + Object.defineProperty(constants, 'LINKED', { + value: false, + writable: true, + configurable: true, + }) + }) + + it('should allow up to 7 custom attributes without warning', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation() + + const attributes = { + attr1: 'value1', + attr2: 'value2', + attr3: 'value3', + attr4: 'value4', + attr5: 'value5', + attr6: 'value6', + attr7: 'value7', + } + + diagnosticsMetrics.recordLatency([0, 1000000], attributes) + + expect(recordedHistogramCalls[0].attributes).toEqual(attributes) + expect(warnSpy).not.toHaveBeenCalled() + + warnSpy.mockRestore() + }) + + it('should limit custom attributes to 7 and warn when exceeded (recordLatency)', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation() + + const attributes = { + attr1: 'value1', + attr2: 'value2', + attr3: 'value3', + attr4: 'value4', + attr5: 'value5', + attr6: 'value6', + attr7: 'value7', + attr8: 'value8', + } + + diagnosticsMetrics.recordLatency([0, 1000000], attributes) + + // Should only include first 7 custom attributes + const recorded = recordedHistogramCalls[0].attributes + expect(Object.keys(recorded)).toHaveLength(7) + expect(recorded).toEqual({ + attr1: 'value1', + attr2: 'value2', + attr3: 'value3', + attr4: 'value4', + attr5: 'value5', + attr6: 'value6', + attr7: 'value7', + }) + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Custom attribute limit exceeded: 8 custom attributes provided, using only the first 7') + ) + + warnSpy.mockRestore() + }) + + it('should limit custom attributes to 7 and warn when exceeded (incrementCounter)', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation() + + const attributes = { + attr1: 'value1', + attr2: 'value2', + attr3: 'value3', + attr4: 'value4', + attr5: 'value5', + attr6: 'value6', + attr7: 'value7', + attr8: 'value8', + } + + diagnosticsMetrics.incrementCounter('test_counter', 1, attributes) + + // Should only include first 7 custom attributes + const recorded = recordedCounterCalls.get('test_counter')![0].attributes + expect(Object.keys(recorded)).toHaveLength(7) + expect(recorded).toEqual({ + attr1: 'value1', + attr2: 'value2', + attr3: 'value3', + attr4: 'value4', + attr5: 'value5', + attr6: 'value6', + attr7: 'value7', + }) + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Custom attribute limit exceeded: 8 custom attributes provided, using only the first 7') + ) + + warnSpy.mockRestore() + }) + + it('should limit custom attributes to 7 and warn when exceeded (setGauge)', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation() + + const attributes = { + attr1: 'value1', + attr2: 'value2', + attr3: 'value3', + attr4: 'value4', + attr5: 'value5', + attr6: 'value6', + attr7: 'value7', + attr8: 'value8', + } + + diagnosticsMetrics.setGauge('test_gauge', 100, attributes) + + // Should only include first 7 custom attributes + const recorded = recordedGaugeCalls.get('test_gauge')![0].attributes + expect(Object.keys(recorded)).toHaveLength(7) + expect(recorded).toEqual({ + attr1: 'value1', + attr2: 'value2', + attr3: 'value3', + attr4: 'value4', + attr5: 'value5', + attr6: 'value6', + attr7: 'value7', + }) + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Custom attribute limit exceeded: 8 custom attributes provided, using only the first 7') + ) + + warnSpy.mockRestore() + }) + }) + + describe('Base Attributes Merging (runWithBaseAttributes)', () => { + beforeEach(async () => { + // Wait for initialization + await new Promise(resolve => setTimeout(resolve, 10)) + }) + + describe('recordLatency', () => { + it('should merge base attributes with custom attributes', () => { + const baseAttributes = { account: 'testaccount', route_id: 'test-route' } + const customAttributes = { operation: 'custom-op', status: 'success' } + + diagnosticsMetrics.runWithBaseAttributes(baseAttributes, () => { + diagnosticsMetrics.recordLatency(100, customAttributes) + }) + + expect(recordedHistogramCalls).toHaveLength(1) + expect(recordedHistogramCalls[0].attributes).toEqual({ + account: 'testaccount', + route_id: 'test-route', + operation: 'custom-op', + status: 'success', + }) + }) + + it('should give base attributes precedence over custom attributes on conflicts', () => { + const baseAttributes = { status: 'base-status', account: 'base-account' } + const customAttributes = { status: 'custom-status', operation: 'test-op' } + + diagnosticsMetrics.runWithBaseAttributes(baseAttributes, () => { + diagnosticsMetrics.recordLatency(100, customAttributes) + }) + + expect(recordedHistogramCalls).toHaveLength(1) + expect(recordedHistogramCalls[0].attributes).toEqual({ + account: 'base-account', + status: 'base-status', // Base takes precedence, custom 'status' is dropped + operation: 'test-op', // Non-conflicting custom attribute is kept + }) + }) + + it('should silently drop conflicting custom attributes without warnings', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation() + + const baseAttributes = { + account: 'base-account', + route_id: 'base-route', + component: 'base-component', + } + const customAttributes = { + account: 'custom-account', // Conflicts - should be dropped + route_id: 'custom-route', // Conflicts - should be dropped + operation: 'custom-op', // No conflict - should be kept + status: 'success', // No conflict - should be kept + } + + diagnosticsMetrics.runWithBaseAttributes(baseAttributes, () => { + diagnosticsMetrics.recordLatency(100, customAttributes) + }) + + // Verify no warnings were logged for conflicting attributes + expect(warnSpy).not.toHaveBeenCalled() + + // Verify base attributes are preserved, conflicting custom attributes dropped + expect(recordedHistogramCalls[0].attributes).toEqual({ + account: 'base-account', // Base preserved + route_id: 'base-route', // Base preserved + component: 'base-component', // Base preserved + operation: 'custom-op', // Non-conflicting custom kept + status: 'success', // Non-conflicting custom kept + }) + + warnSpy.mockRestore() + }) + + it('should use only base attributes when no custom attributes provided', () => { + const baseAttributes = { account: 'testaccount', route_id: 'test-route' } + + diagnosticsMetrics.runWithBaseAttributes(baseAttributes, () => { + diagnosticsMetrics.recordLatency(100) + }) + + expect(recordedHistogramCalls).toHaveLength(1) + expect(recordedHistogramCalls[0].attributes).toEqual(baseAttributes) + }) + + it('should use only custom attributes when outside base attributes context', () => { + const customAttributes = { operation: 'custom-op' } + + diagnosticsMetrics.recordLatency(100, customAttributes) + + expect(recordedHistogramCalls).toHaveLength(1) + expect(recordedHistogramCalls[0].attributes).toEqual(customAttributes) + }) + + it('should work with nested runWithBaseAttributes calls (inner takes precedence)', () => { + const outerBase = { account: 'outer-account', level: 'outer' } + const innerBase = { account: 'inner-account', level: 'inner' } + const customAttributes = { operation: 'test' } + + diagnosticsMetrics.runWithBaseAttributes(outerBase, () => { + diagnosticsMetrics.runWithBaseAttributes(innerBase, () => { + diagnosticsMetrics.recordLatency(100, customAttributes) + }) + }) + + expect(recordedHistogramCalls).toHaveLength(1) + expect(recordedHistogramCalls[0].attributes).toEqual({ + account: 'inner-account', + level: 'inner', + operation: 'test', + }) + }) + }) + + describe('incrementCounter', () => { + it('should merge base attributes with custom attributes', () => { + const baseAttributes = { account: 'testaccount' } + const customAttributes = { method: 'GET' } + + diagnosticsMetrics.runWithBaseAttributes(baseAttributes, () => { + diagnosticsMetrics.incrementCounter('http_requests_total', 1, customAttributes) + }) + + const calls = recordedCounterCalls.get('http_requests_total') + expect(calls).toHaveLength(1) + expect(calls![0].attributes).toEqual({ + account: 'testaccount', + method: 'GET', + }) + }) + + it('should give base attributes precedence over custom attributes on conflicts', () => { + const baseAttributes = { status: 'base', account: 'base-account' } + const customAttributes = { status: 'custom', method: 'GET' } + + diagnosticsMetrics.runWithBaseAttributes(baseAttributes, () => { + diagnosticsMetrics.incrementCounter('test_counter', 1, customAttributes) + }) + + const calls = recordedCounterCalls.get('test_counter') + expect(calls![0].attributes).toEqual({ + status: 'base', // Base takes precedence + account: 'base-account', + method: 'GET', // Non-conflicting custom attribute is kept + }) + }) + }) + + describe('setGauge', () => { + it('should merge base attributes with custom attributes', () => { + const baseAttributes = { environment: 'production' } + const customAttributes = { cache: 'pages' } + + diagnosticsMetrics.runWithBaseAttributes(baseAttributes, () => { + diagnosticsMetrics.setGauge('cache_items_current', 1024, customAttributes) + }) + + const calls = recordedGaugeCalls.get('cache_items_current') + expect(calls).toHaveLength(1) + expect(calls![0].attributes).toEqual({ + environment: 'production', + cache: 'pages', + }) + }) + + it('should give base attributes precedence over custom attributes on conflicts', () => { + const baseAttributes = { type: 'base', environment: 'prod' } + const customAttributes = { type: 'custom', cache: 'pages' } + + diagnosticsMetrics.runWithBaseAttributes(baseAttributes, () => { + diagnosticsMetrics.setGauge('test_gauge', 100, customAttributes) + }) + + const calls = recordedGaugeCalls.get('test_gauge') + expect(calls![0].attributes).toEqual({ + type: 'base', // Base takes precedence + environment: 'prod', + cache: 'pages', // Non-conflicting custom attribute is kept + }) + }) + }) + + describe('async operations', () => { + it('should maintain base attributes context through async operations', async () => { + const baseAttributes = { account: 'async-account' } + const customAttributes = { operation: 'async-op' } + + await diagnosticsMetrics.runWithBaseAttributes(baseAttributes, async () => { + // Simulate async operation + await new Promise(resolve => setTimeout(resolve, 5)) + diagnosticsMetrics.recordLatency(100, customAttributes) + }) + + expect(recordedHistogramCalls).toHaveLength(1) + expect(recordedHistogramCalls[0].attributes).toEqual({ + account: 'async-account', + operation: 'async-op', + }) + }) + + it('should isolate context between concurrent async operations', async () => { + const baseAttrs1 = { account: 'account1' } + const baseAttrs2 = { account: 'account2' } + + await Promise.all([ + diagnosticsMetrics.runWithBaseAttributes(baseAttrs1, async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + diagnosticsMetrics.recordLatency(100, { op: 'op1' }) + }), + diagnosticsMetrics.runWithBaseAttributes(baseAttrs2, async () => { + await new Promise(resolve => setTimeout(resolve, 5)) + diagnosticsMetrics.recordLatency(200, { op: 'op2' }) + }), + ]) + + expect(recordedHistogramCalls).toHaveLength(2) + + // Order might vary due to timing, so check both are present + const attrs = recordedHistogramCalls.map(c => c.attributes) + expect(attrs).toContainEqual({ account: 'account1', op: 'op1' }) + expect(attrs).toContainEqual({ account: 'account2', op: 'op2' }) + }) + }) + + describe('attribute limiting with base attributes', () => { + beforeEach(() => { + // Enable LINKED for these tests so warnings are triggered + const constants = require('../constants') + Object.defineProperty(constants, 'LINKED', { + value: true, + writable: true, + configurable: true, + }) + }) + + afterEach(() => { + // Reset LINKED back to false + const constants = require('../constants') + Object.defineProperty(constants, 'LINKED', { + value: false, + writable: true, + configurable: true, + }) + }) + + it('should limit only custom attributes to 7, not base attributes', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation() + + const baseAttributes = { + base1: 'value1', + base2: 'value2', + base3: 'value3', + base4: 'value4', + } + const customAttributes = { + custom1: 'value1', + custom2: 'value2', + custom3: 'value3', + custom4: 'value4', + custom5: 'value5', + custom6: 'value6', + custom7: 'value7', + custom8: 'value8', // This should be dropped + } + + diagnosticsMetrics.runWithBaseAttributes(baseAttributes, () => { + diagnosticsMetrics.recordLatency(100, customAttributes) + }) + + // 4 base attributes + 7 custom attributes (8th custom dropped) = 11 total + const recorded = recordedHistogramCalls[0].attributes + expect(Object.keys(recorded)).toHaveLength(11) + + // Verify all base attributes are present + expect(recorded.base1).toBe('value1') + expect(recorded.base2).toBe('value2') + expect(recorded.base3).toBe('value3') + expect(recorded.base4).toBe('value4') + + // Verify only first 7 custom attributes are present + expect(recorded.custom1).toBe('value1') + expect(recorded.custom2).toBe('value2') + expect(recorded.custom3).toBe('value3') + expect(recorded.custom4).toBe('value4') + expect(recorded.custom5).toBe('value5') + expect(recorded.custom6).toBe('value6') + expect(recorded.custom7).toBe('value7') + expect(recorded.custom8).toBeUndefined() // 8th custom attribute should be dropped + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Custom attribute limit exceeded: 8 custom attributes provided, using only the first 7') + ) + + warnSpy.mockRestore() + }) + }) + }) +}) diff --git a/src/metrics/DiagnosticsMetrics.ts b/src/metrics/DiagnosticsMetrics.ts new file mode 100644 index 000000000..d0647a4c3 --- /dev/null +++ b/src/metrics/DiagnosticsMetrics.ts @@ -0,0 +1,387 @@ +import { Attributes, context, createContextKey } from '@opentelemetry/api' +import { Types } from '@vtex/diagnostics-nodejs' +import { LINKED, METRIC_CLIENT_INIT_TIMEOUT_MS } from '../constants' +import { getMetricClient } from '../service/metrics/client' + +/** + * Maximum number of custom attributes allowed per metric call to control cardinality. + * This limit applies only to custom attributes provided by callers (VTEX IO Apps). + * Base attributes (set via runWithBaseAttributes) are not counted toward this limit. + * + * Total attributes sent = base attributes + custom attributes (up to MAX_CUSTOM_ATTRIBUTES) + */ +const MAX_CUSTOM_ATTRIBUTES = 7 + +/** + * Context key for storing base attributes in OpenTelemetry context. + * These attributes are automatically merged with custom attributes in all metric methods. + */ +const BASE_ATTRIBUTES_KEY = createContextKey('vtex.metrics.baseAttributes') + +/** + * Converts an hrtime tuple [seconds, nanoseconds] to milliseconds. + */ +function hrtimeToMillis(hrtime: [number, number]): number { + return (hrtime[0] * 1e3) + (hrtime[1] / 1e6) +} + +/** + * Limits the number of custom attributes to prevent high cardinality. + * Takes the first MAX_CUSTOM_ATTRIBUTES entries if the limit is exceeded. + * + * Note: This limit applies only to custom attributes. Base attributes are not limited. + * + * @param customAttributes Optional custom attributes object + * @returns Limited custom attributes object or undefined + */ +function limitCustomAttributes(customAttributes?: Attributes): Attributes | undefined { + if (!customAttributes) { + return undefined + } + + const entries = Object.entries(customAttributes) + if (entries.length <= MAX_CUSTOM_ATTRIBUTES) { + return customAttributes + } + + if (LINKED) { + console.warn( + `Custom attribute limit exceeded: ${entries.length} custom attributes provided, using only the first ${MAX_CUSTOM_ATTRIBUTES}. ` + + `Consider reducing the number of custom attributes to avoid high cardinality. ` + ) + } + + return Object.fromEntries(entries.slice(0, MAX_CUSTOM_ATTRIBUTES)) +} + +/** + * DiagnosticsMetrics provides a high-level API for recording metrics using + * the @vtex/diagnostics-nodejs library. It completely abstracts instrument + * management, bucket configuration, and lifecycle. + * + * Uses a single histogram for all latency measurements with attributes to differentiate. + * This follows OpenTelemetry best practices and reduces metric cardinality. + * + * ## Base Attributes (Request Context) + * + * DiagnosticsMetrics supports automatic merging of request-scoped "base attributes" + * with custom attributes provided in each metric call. This is useful for automatically + * including request context (account, status_code, route_id, etc.) in all metrics + * recorded during a request lifecycle. + * + * Use `runWithBaseAttributes()` to set base attributes for a scope. All metric calls + * within that scope will automatically include these base attributes, merged with + * any custom attributes provided. + * + * **Important:** Base attributes take precedence over custom attributes. If a custom + * attribute has the same key as a base attribute, the custom attribute is silently + * dropped and the base attribute value is used. + * + * @example + * ```typescript + * const diagnosticsMetrics = new DiagnosticsMetrics() + * diagnosticsMetrics.initMetricClient() + * + * // Record latency with operation type in attributes + * const start = process.hrtime() + * // ... do work ... + * diagnosticsMetrics.recordLatency(process.hrtime(start), { operation: 'api-call', status: '2xx' }) + * + * // Or from milliseconds + * diagnosticsMetrics.recordLatency(42.5, { operation: 'db-query', status: 'success' }) + * + * // Increment a counter + * diagnosticsMetrics.incrementCounter('http_requests_total', 1, { method: 'GET', status: '2xx' }) + * + * // Set a gauge value + * diagnosticsMetrics.setGauge('cache_items_current', 1024, { cache: 'pages' }) + * + * // Using base attributes for request context + * await diagnosticsMetrics.runWithBaseAttributes( + * { 'vtex.account.name': 'mystore', status_code: 200 }, + * async () => { + * // All metrics recorded here will include the base attributes + * diagnosticsMetrics.recordLatency(100, { operation: 'custom-op' }) + * // Result: { 'vtex.account.name': 'mystore', status_code: 200, operation: 'custom-op' } + * } + * ) + * ``` + */ +export class DiagnosticsMetrics { + private metricsClient: Types.MetricClient | undefined + private clientInitPromise: Promise | undefined + + private latencyHistogram: Types.Histogram | undefined + // Counters and gauges keyed by name + private counters: Map + private gauges: Map + + constructor() { + this.counters = new Map() + this.gauges = new Map() + this.initMetricClient() + } + + /** + * Initialize the metrics client with timeout handling. + * Called automatically in constructor. + */ + private initMetricClient(): Promise { + if (this.clientInitPromise) { + return this.clientInitPromise + } + + this.clientInitPromise = (async () => { + try { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Metric client initialization timeout')), METRIC_CLIENT_INIT_TIMEOUT_MS) + }) + + this.metricsClient = await Promise.race([ + getMetricClient(), + timeoutPromise, + ]) + + // Create the single latency histogram after client is ready + this.createLatencyHistogram() + + return this.metricsClient + } catch (error) { + console.error('Failed to initialize metric client:', error) + return undefined + } + })() + + return this.clientInitPromise + } + + /** + * Create the single shared histogram for all latency measurements. + * Called after metric client is initialized. + */ + private createLatencyHistogram(): void { + if (!this.metricsClient) { + return + } + + this.latencyHistogram = this.metricsClient.createHistogram('io_app_operation_duration_milliseconds', { + description: 'Duration of VTEX IO app operations in milliseconds', + unit: 'ms', + }) + } + + /** + * Execute a function with base attributes set in the OpenTelemetry context. + * All metric calls within the function will automatically include these base attributes, + * merged with any custom attributes provided in each call. + * + * Base attributes take precedence over custom attributes when there are key conflicts. + * Conflicting custom attributes are silently dropped. + * + * @param baseAttributes Base attributes to include in all metrics within the scope + * @param fn Function to execute with the base attributes context + * @returns The return value of the function + * + * @example + * ```typescript + * // In a request middleware + * await diagnosticsMetrics.runWithBaseAttributes( + * { + * 'vtex.account.name': ctx.vtex.account, + * status_code: ctx.status, + * route_id: ctx.vtex.route.id, + * }, + * async () => { + * await next() + * } + * ) + * + * // In app code (custom attributes are merged with base) + * diagnosticsMetrics.recordLatency(elapsed, { operation: 'my-operation', status: 'success' }) + * // Result includes both base attributes AND custom attributes + * ``` + */ + public runWithBaseAttributes(baseAttributes: Attributes, fn: () => T): T { + const currentContext = context.active() + const newContext = currentContext.setValue(BASE_ATTRIBUTES_KEY, baseAttributes) + return context.with(newContext, fn) + } + + /** + * Get the base attributes from the current OpenTelemetry context. + * Returns undefined if no base attributes are set. + */ + private getBaseAttributes(): Attributes | undefined { + return context.active().getValue(BASE_ATTRIBUTES_KEY) as Attributes | undefined + } + + /** + * Merge base attributes from context with provided custom attributes. + * Base attributes take precedence over custom attributes when there are key conflicts. + * + * Custom attributes are limited to MAX_CUSTOM_ATTRIBUTES before merging. + * Custom attributes with keys that conflict with base attributes are silently dropped. + * Base attributes are not limited. + * + * @param customAttributes Custom attributes provided by the caller + * @returns Merged attributes (base + non-conflicting limited custom) or undefined if both are empty + */ + private mergeAttributes(customAttributes?: Attributes): Attributes | undefined { + const baseAttributes = this.getBaseAttributes() + + // Limit custom attributes before merging + const limitedCustomAttributes = limitCustomAttributes(customAttributes) + + if (!baseAttributes && !limitedCustomAttributes) { + return undefined + } + + if (!baseAttributes) { + return limitedCustomAttributes + } + + if (!limitedCustomAttributes) { + return baseAttributes + } + + // Filter out custom attributes that conflict with base attributes (base takes precedence) + const baseKeys = new Set(Object.keys(baseAttributes)) + const nonConflictingCustomAttributes: Attributes = {} + + for (const [key, value] of Object.entries(limitedCustomAttributes)) { + if (!baseKeys.has(key)) { + nonConflictingCustomAttributes[key] = value + } + // Silently drop conflicting custom attributes - base attributes take precedence + } + + // Merge: base attributes + non-conflicting custom attributes + return { ...baseAttributes, ...nonConflictingCustomAttributes } + } + + /** + * Record a latency measurement using the single shared histogram. + * Accepts either an hrtime tuple from process.hrtime() or milliseconds as a number. + * Use attributes to differentiate between different operations. + * + * Base attributes from the current context (set via `runWithBaseAttributes`) are + * automatically merged with the provided custom attributes. Base attributes take + * precedence - if a custom attribute key conflicts with a base attribute key, + * the custom attribute is silently dropped. + * + * Custom attributes are limited to MAX_CUSTOM_ATTRIBUTES (5). Base attributes are not limited. + * + * @param value Either [seconds, nanoseconds] from process.hrtime() or milliseconds + * @param attributes Custom attributes including 'operation' to identify the operation type (max 5 custom attributes) + * + * @example + * ```typescript + * const start = process.hrtime() + * // ... do work ... + * diagnosticsMetrics.recordLatency(process.hrtime(start), { operation: 'api-call', status: '2xx' }) + * + * // Or with milliseconds + * diagnosticsMetrics.recordLatency(42.5, { operation: 'db-query', status: 'success' }) + * ``` + */ + public recordLatency(value: [number, number] | number, attributes?: Attributes): void { + if (!this.latencyHistogram) { + console.warn('DiagnosticsMetrics not initialized. Call initialize() first.') + return + } + + // Convert hrtime to milliseconds if needed + const milliseconds = Array.isArray(value) ? hrtimeToMillis(value) : value + + // Merge base attributes from context with custom attributes (custom attrs are limited internally) + const mergedAttributes = this.mergeAttributes(attributes) + + // Record to the single shared histogram with merged attributes + this.latencyHistogram.record(milliseconds, mergedAttributes) + } + + /** + * Increment a counter by a specific value. + * Multiple counters are stored by name since counters represent different types of events. + * + * Base attributes from the current context (set via `runWithBaseAttributes`) are + * automatically merged with the provided custom attributes. Base attributes take + * precedence - if a custom attribute key conflicts with a base attribute key, + * the custom attribute is silently dropped. + * + * Custom attributes are limited to MAX_CUSTOM_ATTRIBUTES (5). Base attributes are not limited. + * + * @param name Counter name (e.g., 'http_requests_total', 'cache_hits_total') + * @param value Amount to increment by (typically 1) + * @param attributes Optional custom attributes for the counter (max 5 custom attributes, e.g., { method: 'GET', status: '2xx' }) + * + * @example + * ```typescript + * diagnosticsMetrics.incrementCounter('http_requests_total', 1, { method: 'GET', status: '2xx' }) + * ``` + */ + public incrementCounter(name: string, value: number, attributes?: Attributes): void { + if (!this.metricsClient) { + console.warn('DiagnosticsMetrics not initialized. Call initialize() first.') + return + } + + // Get or create counter instrument + if (!this.counters.has(name)) { + const counter = this.metricsClient.createCounter(name, { + description: `Counter for ${name}`, + unit: '1', + }) + this.counters.set(name, counter) + } + + // Merge base attributes from context with custom attributes (custom attrs are limited internally) + const mergedAttributes = this.mergeAttributes(attributes) + + // Increment the counter + this.counters.get(name)!.add(value, mergedAttributes) + } + + /** + * Set a gauge to a specific value (current state). + * Multiple gauges are stored by name since gauges represent different types of measurements. + * + * Base attributes from the current context (set via `runWithBaseAttributes`) are + * automatically merged with the provided custom attributes. Base attributes take + * precedence - if a custom attribute key conflicts with a base attribute key, + * the custom attribute is silently dropped. + * + * Custom attributes are limited to MAX_CUSTOM_ATTRIBUTES (5). Base attributes are not limited. + * + * @param name Gauge name (e.g., 'cache_items_current', 'memory_usage_bytes') + * @param value Current value + * @param attributes Optional custom attributes for the gauge (max 5 custom attributes, e.g., { cache: 'pages' }) + * + * @example + * ```typescript + * diagnosticsMetrics.setGauge('cache_items_current', 1024, { cache: 'pages' }) + * ``` + */ + public setGauge(name: string, value: number, attributes?: Attributes): void { + if (!this.metricsClient) { + console.warn('DiagnosticsMetrics not initialized. Call initialize() first.') + return + } + + // Get or create gauge instrument + if (!this.gauges.has(name)) { + const gauge = this.metricsClient.createGauge(name, { + description: `Gauge for ${name}`, + unit: '1', + }) + this.gauges.set(name, gauge) + } + + // Merge base attributes from context with custom attributes (custom attrs are limited internally) + const mergedAttributes = this.mergeAttributes(attributes) + + // Set the gauge value + this.gauges.get(name)!.set(value, mergedAttributes) + } +} + diff --git a/src/service/logger/client.test.ts b/src/service/logger/client.test.ts new file mode 100644 index 000000000..28b884823 --- /dev/null +++ b/src/service/logger/client.test.ts @@ -0,0 +1,57 @@ +const mockGetTelemetryClient = jest.fn() +const mockCreateExporter = jest.fn() +const mockCreateLogsExporterConfig = jest.fn() + +jest.mock('@vtex/diagnostics-nodejs', () => ({ + Exporters: { + CreateExporter: mockCreateExporter, + CreateLogsExporterConfig: mockCreateLogsExporterConfig, + }, +})) + +jest.mock('../telemetry', () => ({ + getTelemetryClient: mockGetTelemetryClient, +})) + +describe('logger client', () => { + const logsClient = {} + const logsExporter = { initialize: jest.fn().mockResolvedValue(undefined) } + const telemetryClient = { newLogsClient: jest.fn().mockResolvedValue(logsClient) } + + beforeEach(() => { + jest.clearAllMocks() + jest.resetModules() + + mockGetTelemetryClient.mockResolvedValue(telemetryClient) + mockCreateLogsExporterConfig.mockReturnValue({ signal: 'logs' }) + mockCreateExporter.mockReturnValue(logsExporter) + }) + + it('builds a per-call logger name from account, workspace, and app name', async () => { + const { getLogClient } = require('./client') + + const client = await getLogClient('myaccount', 'myworkspace', 'my-app') + + expect(telemetryClient.newLogsClient).toHaveBeenCalledWith( + expect.objectContaining({ loggerName: 'node-vtex-api-myaccount-myworkspace-my-app' }) + ) + expect(client).toBe(logsClient) + }) + + it('sources the underlying telemetry client from the split telemetry module', async () => { + const { getLogClient } = require('./client') + + await getLogClient('acc', 'ws', 'app') + + expect(mockGetTelemetryClient).toHaveBeenCalled() + }) + + it('caches the logs client across calls', async () => { + const { getLogClient } = require('./client') + + await getLogClient('acc', 'ws', 'app') + await getLogClient('acc', 'ws', 'app') + + expect(telemetryClient.newLogsClient).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/service/logger/client.ts b/src/service/logger/client.ts index 1850d7a5d..b18713f62 100644 --- a/src/service/logger/client.ts +++ b/src/service/logger/client.ts @@ -27,12 +27,7 @@ async function initializeClient(account: string, workspace: string, appName: str const telemetryClient = await getTelemetryClient(); const logsConfig = Exporters.CreateLogsExporterConfig({ - endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, - path: process.env.OTEL_EXPORTER_OTLP_PATH || '/v1/logs', - protocol: 'http', - interval: 5, - timeoutSeconds: 5, - headers: { 'Content-Type': 'application/json' }, + endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT as string, }); const logsExporter = Exporters.CreateExporter(logsConfig, 'otlp'); diff --git a/src/service/metrics/client.ts b/src/service/metrics/client.ts new file mode 100644 index 000000000..475134eec --- /dev/null +++ b/src/service/metrics/client.ts @@ -0,0 +1,46 @@ +import { Types } from '@vtex/diagnostics-nodejs' +import { initializeTelemetry } from '../telemetry' + +class MetricClientSingleton { + private static instance: MetricClientSingleton | undefined + private client: Types.MetricClient | undefined + private initPromise: Promise | undefined + + private constructor() {} + + public static getInstance(): MetricClientSingleton { + if (!MetricClientSingleton.instance) { + MetricClientSingleton.instance = new MetricClientSingleton() + } + return MetricClientSingleton.instance + } + + public async getClient(): Promise { + if (this.client) { + return this.client + } + + if (this.initPromise) { + return this.initPromise + } + + this.initPromise = this.initializeClient() + + return this.initPromise + } + + private async initializeClient(): Promise { + try { + const { metricsClient } = await initializeTelemetry() + this.client = metricsClient + this.initPromise = undefined + return metricsClient + } catch (error) { + console.error('Failed to initialize metrics client:', error) + this.initPromise = undefined + throw error + } + } +} + +export const getMetricClient = () => MetricClientSingleton.getInstance().getClient() diff --git a/src/service/metrics/instruments/hostMetrics.ts b/src/service/metrics/instruments/hostMetrics.ts new file mode 100644 index 000000000..a5add67f6 --- /dev/null +++ b/src/service/metrics/instruments/hostMetrics.ts @@ -0,0 +1,43 @@ +import { MeterProvider } from '@opentelemetry/api' +import { HostMetrics } from '@opentelemetry/host-metrics' +import { InstrumentationBase, InstrumentationConfig } from '@opentelemetry/instrumentation' + +interface HostMetricsInstrumentationConfig extends InstrumentationConfig { + name?: string + meterProvider?: MeterProvider +} + +export class HostMetricsInstrumentation extends InstrumentationBase { + private hostMetrics?: HostMetrics + + constructor(config: HostMetricsInstrumentationConfig = {}) { + const instrumentationName = config.name || 'host-metrics-instrumentation' + const instrumentationVersion = '1.0.0' + super(instrumentationName, instrumentationVersion, config) + } + + public init(): void { + // No-op: instrumentation is started explicitly in enable(). + } + + public enable(): void { + if (!this._config.meterProvider) { + throw new Error('MeterProvider is required for HostMetricsInstrumentation') + } + + this.hostMetrics = new HostMetrics({ + meterProvider: this._config.meterProvider, + name: this._config.name || 'host-metrics', + }) + + this.hostMetrics.start() + console.debug('HostMetricsInstrumentation enabled') + } + + public disable(): void { + if (this.hostMetrics) { + this.hostMetrics = undefined + console.debug('HostMetricsInstrumentation disabled') + } + } +} diff --git a/src/service/telemetry/client.test.ts b/src/service/telemetry/client.test.ts new file mode 100644 index 000000000..7b5e5f6f9 --- /dev/null +++ b/src/service/telemetry/client.test.ts @@ -0,0 +1,190 @@ +const mockNewTelemetryClient = jest.fn() +const mockCreateExporter = jest.fn() +const mockCreateTracesExporterConfig = jest.fn() +const mockCreateMetricsExporterConfig = jest.fn() +const mockCreateLogsExporterConfig = jest.fn() +const mockGetClusterResourceAttributes = jest.fn() + +jest.mock('@vtex/diagnostics-nodejs', () => ({ + Exporters: { + CreateExporter: mockCreateExporter, + CreateLogsExporterConfig: mockCreateLogsExporterConfig, + CreateMetricsExporterConfig: mockCreateMetricsExporterConfig, + CreateTracesExporterConfig: mockCreateTracesExporterConfig, + }, + Instrumentation: { + CommonInstrumentations: { + minimal: jest.fn(() => []), + }, + }, + NewTelemetryClient: mockNewTelemetryClient, +})) + +jest.mock('../../constants', () => ({ + APP: { + ID: 'vtex.test-app@1.0.0', + VENDOR: 'vtex', + VERSION: '1.0.0', + }, + AttributeKeys: { + VTEX_IO_APP_ID: 'vtex_io.app.id', + VTEX_IO_CLUSTER_ID: 'vtex_io.cluster.id', + VTEX_IO_CLUSTER_ROLE: 'vtex_io.cluster.role', + VTEX_IO_WORKSPACE_NAME: 'vtex_io.workspace.name', + VTEX_IO_WORKSPACE_TYPE: 'vtex_io.workspace.type', + }, + CLUSTER_ID: 'cluster-a', + CLUSTER_ROLE: 'stores', + DIAGNOSTICS_TELEMETRY_ENABLED: false, + DK_APP_ID: 'apps-team', + OTEL_EXPORTER_OTLP_ENDPOINT: 'http://collector', + PRODUCTION: true, + WORKSPACE: 'master', +})) + +jest.mock('../metrics/instruments/hostMetrics', () => ({ + HostMetricsInstrumentation: jest.fn(), +})) + +jest.mock('./resourceAttributes', () => ({ + getClusterResourceAttributes: mockGetClusterResourceAttributes, +})) + +import { getTelemetryClient, initializeTelemetry, resetTelemetry } from './client' + +describe('telemetry client', () => { + const tracesClient = {} + const metricsClient = { provider: jest.fn() } + const logsClient = {} + const telemetryClient = { + newLogsClient: jest.fn(), + newMetricsClient: jest.fn(), + newTracesClient: jest.fn(), + registerInstrumentations: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + resetTelemetry() + + mockGetClusterResourceAttributes.mockReturnValue({ + 'vtex_io.cluster.id': 'cluster-a', + 'vtex_io.cluster.role': 'stores', + }) + mockCreateExporter.mockImplementation(config => config) + mockCreateTracesExporterConfig.mockReturnValue({ signal: 'traces' }) + mockCreateMetricsExporterConfig.mockReturnValue({ signal: 'metrics' }) + mockCreateLogsExporterConfig.mockReturnValue({ signal: 'logs' }) + mockNewTelemetryClient.mockResolvedValue(telemetryClient) + telemetryClient.newTracesClient.mockResolvedValue(tracesClient) + telemetryClient.newMetricsClient.mockResolvedValue(metricsClient) + telemetryClient.newLogsClient.mockResolvedValue(logsClient) + }) + + describe('initializeTelemetry', () => { + it('shares configured cluster resource attributes across metrics and logs', async () => { + const clients = await initializeTelemetry() + + expect(mockGetClusterResourceAttributes).toHaveBeenCalledWith('cluster-a', 'stores') + expect(mockNewTelemetryClient).toHaveBeenCalledWith( + 'apps-team', + 'node-vtex-api', + 'vtex.test-app@1.0.0', + expect.objectContaining({ + additionalAttrs: expect.objectContaining({ + 'vtex_io.cluster.id': 'cluster-a', + 'vtex_io.cluster.role': 'stores', + }), + }) + ) + expect(clients).toEqual({ tracesClient, metricsClient, logsClient }) + }) + + it('initializes without cluster dimensions when metadata is unavailable', async () => { + mockGetClusterResourceAttributes.mockReturnValue({}) + + await initializeTelemetry() + + const options = mockNewTelemetryClient.mock.calls[0][3] + expect(options.additionalAttrs).not.toHaveProperty('vtex_io.cluster.id') + expect(options.additionalAttrs).not.toHaveProperty('vtex_io.cluster.role') + }) + + it('caches clients after the first successful initialization', async () => { + await initializeTelemetry() + await initializeTelemetry() + + expect(mockNewTelemetryClient).toHaveBeenCalledTimes(1) + }) + + it('shares a single in-flight initialization across concurrent callers', async () => { + const [first, second] = await Promise.all([initializeTelemetry(), initializeTelemetry()]) + + expect(mockNewTelemetryClient).toHaveBeenCalledTimes(1) + expect(first).toBe(second) + }) + + it('re-initializes after reset', async () => { + await initializeTelemetry() + resetTelemetry() + await initializeTelemetry() + + expect(mockNewTelemetryClient).toHaveBeenCalledTimes(2) + }) + + it('does not register instrumentations when telemetry is disabled', async () => { + await initializeTelemetry() + + expect(telemetryClient.registerInstrumentations).not.toHaveBeenCalled() + }) + }) + + describe('when diagnostics telemetry is enabled', () => { + beforeEach(() => { + jest.resetModules() + jest.doMock('../../constants', () => ({ + APP: { ID: 'vtex.test-app@1.0.0', VENDOR: 'vtex', VERSION: '1.0.0' }, + AttributeKeys: { + VTEX_IO_APP_ID: 'vtex_io.app.id', + VTEX_IO_CLUSTER_ID: 'vtex_io.cluster.id', + VTEX_IO_CLUSTER_ROLE: 'vtex_io.cluster.role', + VTEX_IO_WORKSPACE_NAME: 'vtex_io.workspace.name', + VTEX_IO_WORKSPACE_TYPE: 'vtex_io.workspace.type', + }, + CLUSTER_ID: 'cluster-a', + CLUSTER_ROLE: 'stores', + DIAGNOSTICS_TELEMETRY_ENABLED: true, + DK_APP_ID: 'apps-team', + OTEL_EXPORTER_OTLP_ENDPOINT: 'http://collector', + PRODUCTION: true, + WORKSPACE: 'master', + })) + }) + + it('registers Koa and host-metrics instrumentation', async () => { + const { initializeTelemetry: initializeTelemetryEnabled } = require('./client') + + await initializeTelemetryEnabled() + + expect(telemetryClient.registerInstrumentations).toHaveBeenCalledTimes(1) + const [instrumentations] = telemetryClient.registerInstrumentations.mock.calls[0] + expect(instrumentations.some((i: any) => i.constructor?.name === 'KoaInstrumentation')).toBe(true) + }) + }) + + describe('getTelemetryClient', () => { + it('returns the raw underlying TelemetryClient used to build the split clients', async () => { + const rawClient = await getTelemetryClient() + + expect(rawClient).toBe(telemetryClient) + }) + + it('reuses the same cached raw client as initializeTelemetry', async () => { + await initializeTelemetry() + const rawClient = await getTelemetryClient() + + expect(mockNewTelemetryClient).toHaveBeenCalledTimes(1) + expect(rawClient).toBe(telemetryClient) + }) + }) +}) diff --git a/src/service/telemetry/client.ts b/src/service/telemetry/client.ts index efe704d78..9a3de4239 100644 --- a/src/service/telemetry/client.ts +++ b/src/service/telemetry/client.ts @@ -1,69 +1,172 @@ -import { NewTelemetryClient } from '@vtex/diagnostics-nodejs'; -import { TelemetryClient } from '@vtex/diagnostics-nodejs/dist/telemetry'; -import { APP } from '../../constants'; +import { KoaInstrumentation } from '@opentelemetry/instrumentation-koa' +import { + Exporters, + Instrumentation, + Logs, + Metrics, + NewTelemetryClient, + Traces, +} from '@vtex/diagnostics-nodejs' +import { TelemetryClient } from '@vtex/diagnostics-nodejs/dist/telemetry' +import { + APP, + AttributeKeys, + CLUSTER_ID, + CLUSTER_ROLE, + DIAGNOSTICS_TELEMETRY_ENABLED, + DK_APP_ID, + OTEL_EXPORTER_OTLP_ENDPOINT, + PRODUCTION, + WORKSPACE, +} from '../../constants' +import { HostMetricsInstrumentation } from '../metrics/instruments/hostMetrics' +import { getClusterResourceAttributes } from './resourceAttributes' -class TelemetryClientSingleton { - private static instance: TelemetryClientSingleton; - private telemetryClient: TelemetryClient | undefined; - private initializationPromise: Promise | undefined = undefined; +const APPLICATION_ID = APP.ID || 'vtex-io-app' - private constructor() {} +interface TelemetryClients { + logsClient: Logs.LogClient + metricsClient: Metrics.MetricsClient + tracesClient: Traces.TraceClient +} +class TelemetryClientSingleton { public static getInstance(): TelemetryClientSingleton { if (!TelemetryClientSingleton.instance) { - TelemetryClientSingleton.instance = new TelemetryClientSingleton(); + TelemetryClientSingleton.instance = new TelemetryClientSingleton() + } + return TelemetryClientSingleton.instance + } + + private static instance: TelemetryClientSingleton + private telemetryClients: TelemetryClients | undefined + private rawTelemetryClient: TelemetryClient | undefined + private initializationPromise: Promise | undefined = undefined + + private constructor() {} + + public async getTelemetryClients(): Promise { + if (this.telemetryClients) { + return this.telemetryClients } - return TelemetryClientSingleton.instance; + + if (this.initializationPromise) { + return this.initializationPromise + } + + this.initializationPromise = this.initializeTelemetryClients() + return this.initializationPromise + } + + /** + * Exposes the raw @vtex/diagnostics-nodejs TelemetryClient (rather than one of the three + * pre-built traces/metrics/logs clients) for consumers that need to build their own client + * with per-call configuration — e.g. the structured logger's per-request loggerName. + */ + public async getRawTelemetryClient(): Promise { + await this.getTelemetryClients() + return this.rawTelemetryClient! + } + + public reset(): void { + this.telemetryClients = undefined + this.rawTelemetryClient = undefined + this.initializationPromise = undefined } - private async initTelemetryClient(): Promise { + private initializeTracesClient = async (telemetryClient: TelemetryClient) => + await telemetryClient.newTracesClient({ + exporter: Exporters.CreateExporter(Exporters.CreateTracesExporterConfig({ + endpoint: OTEL_EXPORTER_OTLP_ENDPOINT, + }), 'otlp'), + }) + + private initializeMetricsClient = async (telemetryClient: TelemetryClient) => + await telemetryClient.newMetricsClient({ + exporter: Exporters.CreateExporter(Exporters.CreateMetricsExporterConfig({ + endpoint: OTEL_EXPORTER_OTLP_ENDPOINT, + interval: 60, + temporality: 'delta', + timeoutSeconds: 60, + }), 'otlp'), + }) + + private initializeLogsClient = async (telemetryClient: TelemetryClient) => + await telemetryClient.newLogsClient({ + exporter: Exporters.CreateExporter(Exporters.CreateLogsExporterConfig({ + endpoint: OTEL_EXPORTER_OTLP_ENDPOINT, + }), 'otlp'), + loggerName: `node-vtex-api-${APPLICATION_ID}`, + }) + + private async initializeTelemetryClients(): Promise { try { const telemetryClient = await NewTelemetryClient( + DK_APP_ID, 'node-vtex-api', - APP.ID || 'vtex-app', + APPLICATION_ID, { additionalAttrs: { + [AttributeKeys.VTEX_IO_APP_ID]: APPLICATION_ID, + 'vendor': APP.VENDOR, 'version': APP.VERSION || '', - 'environment': process.env.VTEX_WORKSPACE || 'development', + [AttributeKeys.VTEX_IO_WORKSPACE_NAME]: WORKSPACE, + [AttributeKeys.VTEX_IO_WORKSPACE_TYPE]: PRODUCTION ? 'production' : 'development', + ...getClusterResourceAttributes(CLUSTER_ID, CLUSTER_ROLE), }, + // Use built-in no-op functionality when telemetry is disabled + noop: !DIAGNOSTICS_TELEMETRY_ENABLED, } - ); + ) - this.telemetryClient = telemetryClient; - return telemetryClient; - } catch (error) { - console.error('Failed to initialize telemetry client:', error); - throw error; - } finally { - this.initializationPromise = undefined; - } - } + this.rawTelemetryClient = telemetryClient - public async getClient(): Promise { - if (this.telemetryClient) { - return this.telemetryClient; - } + const [tracesClient, metricsClient, logsClient] = await Promise.all([ + this.initializeTracesClient(telemetryClient), + this.initializeMetricsClient(telemetryClient), + this.initializeLogsClient(telemetryClient), + ]) - if (this.initializationPromise) { - return this.initializationPromise; - } + if (DIAGNOSTICS_TELEMETRY_ENABLED) { + console.log(`Telemetry enabled for app: ${APP.ID} (vendor: ${APP.VENDOR})`) - this.initializationPromise = this.initTelemetryClient(); + const instrumentations = [ + ...Instrumentation.CommonInstrumentations.minimal(), + new KoaInstrumentation(), + new HostMetricsInstrumentation({ + meterProvider: metricsClient.provider(), + name: 'host-metrics-instrumentation', + }), + ] - return this.initializationPromise; - } + telemetryClient.registerInstrumentations(instrumentations) + } - public reset(): void { - this.telemetryClient = undefined; - this.initializationPromise = undefined; + const clients: TelemetryClients = { + logsClient, + metricsClient, + tracesClient, + } + + this.telemetryClients = clients + return clients + } catch (error) { + console.error('Failed to initialize telemetry clients:', error) + throw error + } finally { + this.initializationPromise = undefined + } } +} +export async function initializeTelemetry(): Promise { + return TelemetryClientSingleton.getInstance().getTelemetryClients() } export async function getTelemetryClient(): Promise { - return TelemetryClientSingleton.getInstance().getClient(); + return TelemetryClientSingleton.getInstance().getRawTelemetryClient() } -export function resetTelemetryClient(): void { - TelemetryClientSingleton.getInstance().reset(); +export function resetTelemetry(): void { + TelemetryClientSingleton.getInstance().reset() } diff --git a/src/service/telemetry/resourceAttributes.test.ts b/src/service/telemetry/resourceAttributes.test.ts new file mode 100644 index 000000000..96fa516e9 --- /dev/null +++ b/src/service/telemetry/resourceAttributes.test.ts @@ -0,0 +1,37 @@ +import { AttributeKeys } from '../../constants' +import { getClusterResourceAttributes } from './resourceAttributes' + +describe('getClusterResourceAttributes', () => { + it('maps both cluster values to reserved resource attributes', () => { + expect(getClusterResourceAttributes('cluster-a', 'stores')).toEqual({ + [AttributeKeys.VTEX_IO_CLUSTER_ID]: 'cluster-a', + [AttributeKeys.VTEX_IO_CLUSTER_ROLE]: 'stores', + }) + }) + + it('includes only the cluster identifier when role is missing', () => { + expect(getClusterResourceAttributes('cluster-a', undefined)).toEqual({ + [AttributeKeys.VTEX_IO_CLUSTER_ID]: 'cluster-a', + }) + }) + + it('includes only the cluster role when identifier is missing', () => { + expect(getClusterResourceAttributes(undefined, 'stores')).toEqual({ + [AttributeKeys.VTEX_IO_CLUSTER_ROLE]: 'stores', + }) + }) + + it('omits missing and empty values', () => { + expect(getClusterResourceAttributes('', undefined)).toEqual({}) + expect(getClusterResourceAttributes(undefined, undefined)).toEqual({}) + }) + + it('trims values and omits whitespace-only values independently', () => { + expect(getClusterResourceAttributes(' cluster-a ', ' ')).toEqual({ + [AttributeKeys.VTEX_IO_CLUSTER_ID]: 'cluster-a', + }) + expect(getClusterResourceAttributes(' ', ' stores ')).toEqual({ + [AttributeKeys.VTEX_IO_CLUSTER_ROLE]: 'stores', + }) + }) +}) diff --git a/src/service/telemetry/resourceAttributes.ts b/src/service/telemetry/resourceAttributes.ts new file mode 100644 index 000000000..a6bdfe148 --- /dev/null +++ b/src/service/telemetry/resourceAttributes.ts @@ -0,0 +1,25 @@ +import { AttributeKeys } from '../../constants' + +const normalizeAttribute = (value?: string): string | undefined => { + const normalized = value?.trim() + return normalized || undefined +} + +export const getClusterResourceAttributes = ( + clusterId?: string, + clusterRole?: string +): Record => { + const attributes: Record = {} + const normalizedClusterId = normalizeAttribute(clusterId) + const normalizedClusterRole = normalizeAttribute(clusterRole) + + if (normalizedClusterId) { + attributes[AttributeKeys.VTEX_IO_CLUSTER_ID] = normalizedClusterId + } + + if (normalizedClusterRole) { + attributes[AttributeKeys.VTEX_IO_CLUSTER_ROLE] = normalizedClusterRole + } + + return attributes +} diff --git a/yarn.lock b/yarn.lock index 1d6371ed9..1a41b95b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -236,6 +236,14 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" +"@grpc/grpc-js@^1.13.4": + version "1.14.4" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.14.4.tgz#e73ff57d97802f063999545f43ebb2b1eca65d9d" + integrity sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ== + dependencies: + "@grpc/proto-loader" "^0.8.0" + "@js-sdsl/ordered-map" "^4.4.2" + "@grpc/grpc-js@^1.7.1": version "1.13.3" resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.13.3.tgz#6ad08d186c2a8651697085f790c5c68eaca45904" @@ -254,6 +262,16 @@ protobufjs "^7.2.5" yargs "^17.7.2" +"@grpc/proto-loader@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.8.1.tgz#5a6b290ccbfb1ae2f6775afb74e9898bd8c5d4e8" + integrity sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.5.5" + yargs "^17.7.2" + "@istanbuljs/load-nyc-config@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.0.0.tgz#10602de5570baea82f8afbfa2630b24e7a8cfe5b" @@ -470,12 +488,19 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== -"@opentelemetry/context-async-hooks@1.30.1": +"@opentelemetry/baggage-span-processor@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/baggage-span-processor/-/baggage-span-processor-0.3.1.tgz#8bca006ad0ca5e43d452a615ac2469a09cab7711" + integrity sha512-m4XXch3/NraA0XEogdQgdMbhg0ZWQWnwXRxuWZJLskIFvIatvUZwoWZm+8gApEZNJNz/Jk/dwtMylUQZBwcyYA== + dependencies: + "@opentelemetry/sdk-trace-base" "^1.0.0" + +"@opentelemetry/context-async-hooks@1.30.1", "@opentelemetry/context-async-hooks@^1.30.1": version "1.30.1" resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz#4f76280691a742597fd0bf682982126857622948" integrity sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA== -"@opentelemetry/core@1.30.1", "@opentelemetry/core@^1.0.0", "@opentelemetry/core@^1.30.1": +"@opentelemetry/core@1.30.1", "@opentelemetry/core@^1.0.0", "@opentelemetry/core@^1.30.1", "@opentelemetry/core@^1.8.0": version "1.30.1" resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.30.1.tgz#a0b468bb396358df801881709ea38299fc30ab27" integrity sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ== @@ -494,7 +519,7 @@ "@opentelemetry/otlp-transformer" "0.57.2" "@opentelemetry/sdk-logs" "0.57.2" -"@opentelemetry/exporter-logs-otlp-http@0.57.2", "@opentelemetry/exporter-logs-otlp-http@^0.57.2": +"@opentelemetry/exporter-logs-otlp-http@0.57.2": version "0.57.2" resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.57.2.tgz#01d4668b8f781540f94592da9284b92fd6a2ccd8" integrity sha512-0rygmvLcehBRp56NQVLSleJ5ITTduq/QfU7obOkyWgPpFHulwpw2LYTqNIz5TczKZuy5YY+5D3SDnXZL1tXImg== @@ -532,7 +557,7 @@ "@opentelemetry/resources" "1.30.1" "@opentelemetry/sdk-metrics" "1.30.1" -"@opentelemetry/exporter-metrics-otlp-http@0.57.2", "@opentelemetry/exporter-metrics-otlp-http@^0.57.2": +"@opentelemetry/exporter-metrics-otlp-http@0.57.2": version "0.57.2" resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.57.2.tgz#0983b28a4a36dee3af2c258394214004e4c68b53" integrity sha512-ttb9+4iKw04IMubjm3t0EZsYRNWr3kg44uUuzfo9CaccYlOh8cDooe4QObDUkvx9d5qQUrbEckhrWKfJnKhemA== @@ -577,7 +602,7 @@ "@opentelemetry/resources" "1.30.1" "@opentelemetry/sdk-trace-base" "1.30.1" -"@opentelemetry/exporter-trace-otlp-http@0.57.2", "@opentelemetry/exporter-trace-otlp-http@^0.57.2": +"@opentelemetry/exporter-trace-otlp-http@0.57.2": version "0.57.2" resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.57.2.tgz#0ab8e97dc30dbabb8252b68128b80c4685f7c691" integrity sha512-sB/gkSYFu+0w2dVQ0PWY9fAMl172PKMZ/JrHkkW8dmjCL0CYkmXeE+ssqIL/yBUTPOvpLIpenX5T9RwXRBW/3g== @@ -609,6 +634,22 @@ "@opentelemetry/sdk-trace-base" "1.30.1" "@opentelemetry/semantic-conventions" "1.28.0" +"@opentelemetry/host-metrics@0.35.5": + version "0.35.5" + resolved "https://registry.yarnpkg.com/@opentelemetry/host-metrics/-/host-metrics-0.35.5.tgz#1bb7453558b2623c8331d0fea5b7766c995a68f1" + integrity sha512-Zf9Cjl7H6JalspnK5KD1+LLKSVecSinouVctNmUxRy+WP+20KwHq+qg4hADllkEmJ99MZByLLmEmzrr7s92V6g== + dependencies: + systeminformation "5.23.8" + +"@opentelemetry/instrumentation-express@^0.47.1": + version "0.47.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-express/-/instrumentation-express-0.47.1.tgz#7cf74f35e43cc3c8186edd1249fdb225849c48b2" + integrity sha512-QNXPTWteDclR2B4pDFpz0TNghgB33UMjUt14B+BZPmtH1MwUFAfLHBaP5If0Z5NZC+jaH8oF2glgYjrmhZWmSw== + dependencies: + "@opentelemetry/core" "^1.8.0" + "@opentelemetry/instrumentation" "^0.57.1" + "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation-http@^0.57.2": version "0.57.2" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-http/-/instrumentation-http-0.57.2.tgz#f425eda67b6241c3abe08e4ea972169b85ef3064" @@ -620,6 +661,15 @@ forwarded-parse "2.1.2" semver "^7.5.2" +"@opentelemetry/instrumentation-koa@0.47.1": + version "0.47.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.47.1.tgz#ba57eccd44a75ec59e3129757fda4e8c8dd7ce2c" + integrity sha512-l/c+Z9F86cOiPJUllUCt09v+kICKvT+Vg1vOAJHtHPsJIzurGayucfCMq2acd/A/yxeNWunl9d9eqZ0G+XiI6A== + dependencies: + "@opentelemetry/core" "^1.8.0" + "@opentelemetry/instrumentation" "^0.57.1" + "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation-net@^0.43.1": version "0.43.1" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-net/-/instrumentation-net-0.43.1.tgz#10a3030fe090ed76204ac025179501f902dcf282" @@ -745,7 +795,7 @@ "@opentelemetry/sdk-trace-node" "1.30.1" "@opentelemetry/semantic-conventions" "1.28.0" -"@opentelemetry/sdk-trace-base@1.30.1", "@opentelemetry/sdk-trace-base@^1.30.1": +"@opentelemetry/sdk-trace-base@1.30.1", "@opentelemetry/sdk-trace-base@^1.0.0", "@opentelemetry/sdk-trace-base@^1.30.1": version "1.30.1" resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz#41a42234096dc98e8f454d24551fc80b816feb34" integrity sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg== @@ -791,11 +841,21 @@ resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb" integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg== +"@protobufjs/codegen@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.5.tgz#d9315ad7cf3f30aac70bda3c068443dc6f143659" + integrity sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g== + "@protobufjs/eventemitter@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70" integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== +"@protobufjs/eventemitter@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz#d512cb26c0ae026091ee2c1167f1be6faf5c842a" + integrity sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg== + "@protobufjs/fetch@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45" @@ -804,6 +864,13 @@ "@protobufjs/aspromise" "^1.1.1" "@protobufjs/inquire" "^1.1.0" +"@protobufjs/fetch@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.1.tgz#4d6fc00c8fb64016a5c81b469d549046350f1065" + integrity sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw== + dependencies: + "@protobufjs/aspromise" "^1.1.1" + "@protobufjs/float@^1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" @@ -829,6 +896,11 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@protobufjs/utf8@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.2.tgz#78d476333d85d5b1c792e257bca74ba080da49a4" + integrity sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug== + "@sinonjs/commons@^1.7.0": version "1.7.1" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.7.1.tgz#da5fd19a5f71177a53778073978873964f49acf1" @@ -1219,21 +1291,22 @@ dependencies: "@types/yargs-parser" "*" -"@vtex/diagnostics-nodejs@0.1.0-beta.10": - version "0.1.0-beta.10" - resolved "https://registry.yarnpkg.com/@vtex/diagnostics-nodejs/-/diagnostics-nodejs-0.1.0-beta.10.tgz#af255418c0777bf49d02f1e650d654d20f11e513" - integrity sha512-w5IOo+P1RcGXYZZw5RV4guQFIKpIqmq7reEQRx6qJYDh0RwLFFhr7NS8MNGm792xOs59hyYMulhx8FMmcOXVxA== +"@vtex/diagnostics-nodejs@0.1.8-io": + version "0.1.8-io" + resolved "https://registry.yarnpkg.com/@vtex/diagnostics-nodejs/-/diagnostics-nodejs-0.1.8-io.tgz#ca5679779b8bac8e3c91288b35e418836201a2c2" + integrity sha512-Nm9fF/tpRP38Pt9wKlPE/RUttKP4mVRnKqUskkul/yBSdlYNKJQ366YIlZXgizAApt8oiJJb5oL0i2GBgSXSag== dependencies: + "@grpc/grpc-js" "^1.13.4" "@opentelemetry/api" "^1.9.0" "@opentelemetry/api-logs" "^0.200.0" + "@opentelemetry/baggage-span-processor" "^0.3.1" + "@opentelemetry/context-async-hooks" "^1.30.1" "@opentelemetry/core" "^1.30.1" "@opentelemetry/exporter-logs-otlp-grpc" "^0.57.2" - "@opentelemetry/exporter-logs-otlp-http" "^0.57.2" "@opentelemetry/exporter-metrics-otlp-grpc" "^0.57.2" - "@opentelemetry/exporter-metrics-otlp-http" "^0.57.2" "@opentelemetry/exporter-trace-otlp-grpc" "^0.57.2" - "@opentelemetry/exporter-trace-otlp-http" "^0.57.2" "@opentelemetry/instrumentation" "^0.57.2" + "@opentelemetry/instrumentation-express" "^0.47.1" "@opentelemetry/instrumentation-http" "^0.57.2" "@opentelemetry/instrumentation-net" "^0.43.1" "@opentelemetry/propagator-b3" "^1.30.1" @@ -1245,14 +1318,19 @@ "@opentelemetry/sdk-trace-base" "^1.30.1" "@opentelemetry/sdk-trace-node" "^1.30.1" "@opentelemetry/semantic-conventions" "^1.30.0" - "@vtex/diagnostics-semconv" "0.1.0-beta.10" + "@vtex/diagnostics-semconv" "0.1.0-beta.11" tslib "^2.8.1" uuid "^11.1.0" -"@vtex/diagnostics-semconv@0.1.0-beta.10": - version "0.1.0-beta.10" - resolved "https://registry.yarnpkg.com/@vtex/diagnostics-semconv/-/diagnostics-semconv-0.1.0-beta.10.tgz#f5b6aa4444cbb4bc1fb0c9e38ca52594d9c2b939" - integrity sha512-a5D8tlBJjBqJBTPsbm3la4gy6hiduWyTTWzjOqoICdgsmCNB9E8mw6NcloEMkBILQ7n8X3EDfXZvkea6OILibQ== +"@vtex/diagnostics-semconv@0.1.0-beta.11": + version "0.1.0-beta.11" + resolved "https://registry.yarnpkg.com/@vtex/diagnostics-semconv/-/diagnostics-semconv-0.1.0-beta.11.tgz#2ddfff7dffdc1c052d23b335f914de91653d9659" + integrity sha512-H3KM5fYAFmcxhlA4wT5iPgWJtgKsumFqGkkxjcA/BSwC5tgSWezN82sZDKvBsVo24EoZxVGgLlsjNw1tsp9U3Q== + +"@vtex/diagnostics-semconv@5.5.2": + version "5.5.2" + resolved "https://registry.yarnpkg.com/@vtex/diagnostics-semconv/-/diagnostics-semconv-5.5.2.tgz#87eeaed851b8bea8ef46f1211552324f98e345f6" + integrity sha512-2uQNJVfLjbhay2pq1pVIIrSe7x7rsIyqIVD9z7syez4Sz/lzlwxNvRfJPt+Sg7MeGraK4+/YiSEO5kgpxGPRLg== "@vtex/node-error-report@^0.0.3": version "0.0.3" @@ -3934,7 +4012,7 @@ long@^2.4.0: resolved "https://registry.yarnpkg.com/long/-/long-2.4.0.tgz#9fa180bb1d9500cdc29c4156766a1995e1f4524f" integrity sha1-n6GAux2VAM3CnEFWdmoZleH0Uk8= -long@^5.0.0: +long@^5.0.0, long@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== @@ -4533,6 +4611,23 @@ protobufjs@^7.2.5, protobufjs@^7.3.0: "@types/node" ">=13.7.0" long "^5.0.0" +protobufjs@^7.5.5: + version "7.6.6" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.6.6.tgz#7a3923e8e32b0ee2ff689f8eed6e455c090ac67e" + integrity sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.5" + "@protobufjs/eventemitter" "^1.1.1" + "@protobufjs/fetch" "^1.1.1" + "@protobufjs/float" "^1.0.2" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.1" + "@types/node" ">=13.7.0" + long "^5.3.2" + proxy-from-env@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" @@ -5236,6 +5331,11 @@ symbol-tree@^3.2.2: resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== +systeminformation@5.23.8: + version "5.23.8" + resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-5.23.8.tgz#b8efa73b36221cbcb432e3fe83dc1878a43f986a" + integrity sha512-Osd24mNKe6jr/YoXLLK3k8TMdzaxDffhpCxgkfgBHcapykIkd50HXThM3TCEuHO2pPuCsSx2ms/SunqhU5MmsQ== + tar-fs@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.0.0.tgz#677700fc0c8b337a78bee3623fdc235f21d7afad" From 1d79b26571dda6c70490068fd85b5e1d864128e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlio=20Guedes?= Date: Thu, 17 Sep 2026 12:12:55 -0300 Subject: [PATCH 2/7] chore(release): bump version to 6.53.0 and finalize CHANGELOG entry Follows the same convention as the preceding 6.52.0 backport commit: version bump and changelog entry land together with the feature. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aadc6fbe3..73865eb55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [6.53.0] ### Added - Diagnostics metrics observability, backported from the `master` (7.x) line: `DiagnosticsMetrics` (`recordLatency`, `incrementCounter`, `setGauge`, `runWithBaseAttributes`), a split diff --git a/package.json b/package.json index 8def5a5db..e1dea0929 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vtex/api", - "version": "6.52.0", + "version": "6.53.0", "description": "VTEX I/O API client", "main": "lib/index.js", "typings": "lib/index.d.ts", From 1dc17fe629f99f995fbd5a2723885d494df80f87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlio=20Guedes?= Date: Thu, 17 Sep 2026 12:14:32 -0300 Subject: [PATCH 3/7] chore(release): use 6.53.0-beta.0 for beta testing Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73865eb55..de0e3cdd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## [6.53.0] +## [6.53.0-beta.0] ### Added - Diagnostics metrics observability, backported from the `master` (7.x) line: `DiagnosticsMetrics` (`recordLatency`, `incrementCounter`, `setGauge`, `runWithBaseAttributes`), a split diff --git a/package.json b/package.json index e1dea0929..4c8293230 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vtex/api", - "version": "6.53.0", + "version": "6.53.0-beta.0", "description": "VTEX I/O API client", "main": "lib/index.js", "typings": "lib/index.d.ts", From 13640bdd653fb59d8e3e9583e90d4593c3586f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlio=20Guedes?= Date: Thu, 17 Sep 2026 14:28:38 -0300 Subject: [PATCH 4/7] feat(telemetry): wire DiagnosticsMetrics into the request pipeline Closes the gap found after deploying 6.53.0-beta.0 to iotest-ju2: the previous commit ported the DiagnosticsMetrics API and telemetry client, but nothing in 6.x's request pipeline ever called them, so no metrics reached ClickHouse despite the feature flag being enabled. - service/index.ts: startApp() now calls initializeTelemetry() and sets global.diagnosticsMetrics before serving requests. - timings.ts: per-request HTTP handler latency + counter, with runWithBaseAttributes-scoped account/route context. - requestStats.ts: request closed/aborted/total counters. - HttpClient/middlewares/metrics.ts: outbound HTTP client metrics (latency, request/cache/retry counters). - HttpAgentSingleton.ts + statusTrack.ts: HTTP agent socket gauges, updated periodically via trackStatus(). - schemaDirectives/Metric.ts: the @metric GraphQL directive now emits through DiagnosticsMetrics too. All five emission points degrade gracefully (warn + skip) when global.diagnosticsMetrics is unavailable, matching master. Ported master's own tests verbatim where they exist; added a real-class test for the GraphQL directive since master's own test for it never actually imports the real class. 180/180 tests pass (up from 88, since the branch also picked up an unrelated Prometheus-aggregation backport's suites during rebase); same single pre-existing, unrelated axiosTracing.test.ts failure as before. yarn build compiles clean. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 7 +- .../changes/add-observability-to-6x/tasks.md | 37 +- src/HttpClient/middlewares/metrics.test.ts | 335 +++++++++++++++++ src/HttpClient/middlewares/metrics.ts | 63 +++- .../request/HttpAgentSingleton.test.ts | 102 ++++++ .../middlewares/request/HttpAgentSingleton.ts | 18 + src/service/index.test.ts | 56 +++ src/service/index.ts | 15 +- .../runtime/__tests__/statusTrack.test.ts | 24 +- .../schema/schemaDirectives/Metric.test.ts | 341 ++++++++++++++++++ .../graphql/schema/schemaDirectives/Metric.ts | 27 +- .../http/middlewares/requestStats.test.ts | 194 ++++++++++ .../runtime/http/middlewares/requestStats.ts | 51 ++- .../runtime/http/middlewares/timings.test.ts | 262 ++++++++++++++ .../runtime/http/middlewares/timings.ts | 66 +++- src/service/worker/runtime/statusTrack.ts | 4 + 16 files changed, 1560 insertions(+), 42 deletions(-) create mode 100644 src/HttpClient/middlewares/metrics.test.ts create mode 100644 src/HttpClient/middlewares/request/HttpAgentSingleton.test.ts create mode 100644 src/service/index.test.ts create mode 100644 src/service/worker/runtime/graphql/schema/schemaDirectives/Metric.test.ts create mode 100644 src/service/worker/runtime/http/middlewares/requestStats.test.ts create mode 100644 src/service/worker/runtime/http/middlewares/timings.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index de0e3cdd9..16f26706f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. (`recordLatency`, `incrementCounter`, `setGauge`, `runWithBaseAttributes`), a split traces/metrics/logs telemetry client (`@vtex/diagnostics-nodejs@0.1.8-io`, `@vtex/diagnostics-semconv`), cluster resource attributes, and automatic Koa + host-metrics - instrumentation. Disabled by default; opt in per app with - `VTEX_DIAGNOSTICS_TELEMETRY_ENABLED=true`. + instrumentation. Metrics are wired into the request pipeline itself, not just exposed as a + library API: HTTP handler latency/counters, request closed/aborted/total counters, outbound + HTTP client metrics, HTTP agent socket gauges, and the `@metric` GraphQL directive all emit + through `DiagnosticsMetrics` at the same points `master` does. Disabled by default; opt in + per app with `VTEX_DIAGNOSTICS_TELEMETRY_ENABLED=true`. ## [6.52.0] ### Added diff --git a/openspec/changes/add-observability-to-6x/tasks.md b/openspec/changes/add-observability-to-6x/tasks.md index 8e8e7c1ae..f33477ad5 100644 --- a/openspec/changes/add-observability-to-6x/tasks.md +++ b/openspec/changes/add-observability-to-6x/tasks.md @@ -51,28 +51,29 @@ Each numbered group below follows red → green → refactor: write the failing Deploying `6.53.0-beta.0` to the `iotest-ju2` test cluster with `DIAGNOSTICS_TELEMETRY_ENABLED=true` surfaced that no metrics were reaching ClickHouse. Root cause: groups 1–7 ported the `DiagnosticsMetrics` API and telemetry client, but never wired them into `6.x`'s actual request pipeline — `service/index.ts` never called `initializeTelemetry()`/set `global.diagnosticsMetrics`, and none of `master`'s five consumer call sites exist on `6.x` yet. This group closes that gap. -- [ ] 8.1 **Red**: write/extend a test for `src/service/index.ts`'s `startApp()` asserting it calls `initializeTelemetry()` and sets `global.diagnosticsMetrics` to a `DiagnosticsMetrics` instance before serving requests; confirm it fails against the current implementation -- [ ] 8.2 **Green**: update `startApp()` to call `await initializeTelemetry()` and set `global.diagnosticsMetrics = new DiagnosticsMetrics()`, matching `master`; declare the `global.diagnosticsMetrics` type augmentation -- [ ] 8.3 **Red**: extend `src/service/worker/runtime/http/middlewares/timings.ts`'s test coverage with the "HTTP handler latency and counter" scenario (base attributes via `runWithBaseAttributes`, `recordLatency`, `incrementCounter('http_handler_requests_total', ...)`, graceful degradation when `global.diagnosticsMetrics` is unavailable); confirm it fails -- [ ] 8.4 **Green**: port `master`'s `global.diagnosticsMetrics` emission logic into `timings.ts` to pass 8.3 -- [ ] 8.5 **Red**: extend `src/service/worker/runtime/http/middlewares/requestStats.ts`'s test coverage with the "Request lifecycle counters" scenario (closed/aborted/total); confirm it fails -- [ ] 8.6 **Green**: port `master`'s `global.diagnosticsMetrics` emission logic into `requestStats.ts` to pass 8.5 -- [ ] 8.7 **Red**: extend `src/HttpClient/middlewares/metrics.ts`'s test coverage with the "Outbound HTTP client metrics" scenario; confirm it fails -- [ ] 8.8 **Green**: port `master`'s `global.diagnosticsMetrics` emission logic into `HttpClient/middlewares/metrics.ts` to pass 8.7 -- [ ] 8.9 **Red**: extend `src/HttpClient/middlewares/request/HttpAgentSingleton.ts`'s test coverage with the "HTTP agent metrics" scenario; confirm it fails -- [ ] 8.10 **Green**: port `master`'s `global.diagnosticsMetrics` emission logic into `HttpAgentSingleton.ts` to pass 8.9 -- [ ] 8.11 **Red**: extend `src/service/worker/runtime/graphql/schema/schemaDirectives/Metric.ts`'s test coverage with the "GraphQL `@metric` directive" scenario; confirm it fails -- [ ] 8.12 **Green**: port `master`'s `global.diagnosticsMetrics` emission logic into `Metric.ts` to pass 8.11 -- [ ] 8.13 **Refactor**: confirm every emission point uses the same `if (global.diagnosticsMetrics) { ... } else { console.warn(...) }` guard shape as `master`, with no duplicated boilerplate beyond what `master` itself has +- [x] 8.1 **Red**: added `src/service/index.test.ts` asserting `startApp()` calls `initializeTelemetry()` (before `startMaster`, via `invocationCallOrder`) and sets `global.diagnosticsMetrics` to a usable `DiagnosticsMetrics` instance; confirmed it fails on the missing `global.diagnosticsMetrics` type augmentation +- [x] 8.2 **Green**: `startApp()` now `await initializeTelemetry()`s and sets `global.diagnosticsMetrics = new DiagnosticsMetrics()` before the master/worker branch, matching `master`; added the `NodeJS.Global` type augmentation. Note: the test asserts `initializeTelemetry` was called (not an exact count) because `DiagnosticsMetrics`'s own constructor also triggers it via `getMetricClient()` — the real singleton dedupes this, an exact-count assertion would just be testing a coincidence of the mock setup. +- [x] 8.3 **Red**: ported `master`'s `src/service/worker/runtime/http/middlewares/timings.test.ts` verbatim (19 tests: base attributes, latency/counter recording, all status-code categories, graceful degradation); confirmed it fails against the pre-wiring `timings.ts` +- [x] 8.4 **Green**: ported `master`'s `timings.ts` wiring (`runWithBaseAttributes`, `recordLatency`, `incrementCounter('http_handler_requests_total', ...)`) verbatim to pass 8.3 +- [x] 8.5 **Red**: ported `master`'s `requestStats.test.ts` verbatim; confirmed it fails against the pre-wiring `requestStats.ts` +- [x] 8.6 **Green**: ported `master`'s `requestStats.ts` wiring (closed/aborted/total counters) verbatim to pass 8.5 +- [x] 8.7 **Red**: ported `master`'s `HttpClient/middlewares/metrics.test.ts` verbatim (13 tests); confirmed it fails against the pre-wiring `metrics.ts` +- [x] 8.8 **Green**: ported `master`'s `metrics.ts` wiring, substituting `ACCOUNT_HEADER` for `master`'s `HeaderKeys.ACCOUNT` (the `HeaderKeys` refactor stays out of scope per design.md) to pass 8.7 +- [x] 8.9 **Red**: ported `master`'s `HttpAgentSingleton.test.ts` verbatim (covers the new `updateHttpAgentMetrics()` static method); confirmed it fails on the missing method +- [x] 8.10 **Green**: added `HttpAgentSingleton.updateHttpAgentMetrics()` (gauges for sockets/free sockets/pending requests) to pass 8.9 — **plus one addition beyond the literal spec scenario list**: wired its only caller, `statusTrack.ts`'s `trackStatus()` (matching `master`), with a new test in `statusTrack.test.ts`; without this the method exists but nothing ever calls it periodically +- [x] 8.11 **Red**: ported `master`'s `Metric.test.ts`, but added a new `describe` block that exercises the *real* `Metric` class directly (`Object.create(Metric.prototype)` + manual `.args`) — the ported test from `master` only reimplements the resolver logic inline and never imports the real class, so it would have passed trivially without any implementation change; confirmed the new block fails against the pre-wiring `Metric.ts` +- [x] 8.12 **Green**: ported `master`'s `Metric.ts` wiring (`recordLatency`, `incrementCounter('graphql_field_requests_total', ...)`) to pass 8.11 +- [x] 8.13 **Refactor**: confirmed every emission point uses the same `if (global.diagnosticsMetrics) { ... } else { console.warn(...) }` guard shape as `master`, with no duplicated boilerplate beyond what `master` itself has ## 9. Full-suite regression and manual verification - [x] 9.1 Ran the complete `6.x` jest suite after groups 1–7: 88/88 tests pass across 8 suites; 1 pre-existing suite (`axiosTracing.test.ts`) fails on an unrelated TypeScript strictness error in `TestServer.ts` (`resolve()` called with no argument) — confirmed pre-existing via unchanged `yarn.lock` `typescript@4.9.5` resolution and a zero-diff on that file; not caused by this change -- [ ] 9.2 Re-run the full jest suite after group 8 lands; confirm no regressions -- [x] 9.3 Manually verify in a non-production workspace with `DIAGNOSTICS_TELEMETRY_ENABLED=true` — **done, and this is what surfaced the group-8 gap**: deployed `6.53.0-beta.0` to the `iotest-ju2` cluster; telemetry clients initialize (per the flag) but no per-request metrics reached ClickHouse, because nothing called the emission points. Re-verify after group 8 lands that `io_app_operation_duration_milliseconds` and the HTTP/GraphQL counters actually arrive. -- [ ] 9.4 Manually verify with the flag unset in a live workspace: no telemetry initialization side effects (still `noop: true`) and app behavior unchanged from the pre-change baseline +- [x] 9.2 Re-ran the full jest suite after group 8: 180/180 tests pass across 20 suites (up from 88/8 — the rebase onto `6.x`'s tip also picked up an unrelated Prometheus-aggregation backport's own new suites); same single pre-existing `axiosTracing.test.ts` failure, unchanged. `yarn build` compiles clean. +- [x] 9.3 Manually verify in a non-production workspace with `DIAGNOSTICS_TELEMETRY_ENABLED=true` — **done, and this is what surfaced the group-8 gap**: deployed `6.53.0-beta.0` to the `iotest-ju2` cluster; telemetry clients initialize (per the flag) but no per-request metrics reached ClickHouse, because nothing called the emission points. +- [ ] 9.4 Re-verify on `iotest-ju2` (or another test cluster) with a build that includes group 8: confirm `io_app_operation_duration_milliseconds`, `http_handler_requests_total`, `http_server_requests_*_total`, `http_client_requests_total`, `http_agent_*_current`, and `graphql_field_requests_total` all actually reach ClickHouse — not performed in this session, needs a live deploy +- [ ] 9.5 Manually verify with the flag unset in a live workspace: no telemetry initialization side effects (still `noop: true`) and app behavior unchanged from the pre-change baseline — not performed in this session ## 10. Documentation and release -- [x] 10.1 Added a `CHANGELOG.md` entry (currently under `[6.53.0-beta.0]`) on the `6.x` branch describing the diagnostics metrics capability and the `DIAGNOSTICS_TELEMETRY_ENABLED` flag — update this entry once group 8 lands to mention that metrics are now actually wired into the request pipeline, not just available as a library API -- [ ] 10.2 Release a stable `6.x` version of `node-vtex-api` including this change, once group 8 is verified end-to-end on a test cluster +- [x] 10.1 `CHANGELOG.md` entry exists under `[6.53.0-beta.0]` on the `6.x` branch describing the diagnostics metrics capability and the `DIAGNOSTICS_TELEMETRY_ENABLED` flag; updated to mention metrics are now wired into the request pipeline, not just available as a library API +- [ ] 10.2 Release a stable `6.x` version of `node-vtex-api` including this change, once 9.4/9.5 are verified end-to-end on a test cluster diff --git a/src/HttpClient/middlewares/metrics.test.ts b/src/HttpClient/middlewares/metrics.test.ts new file mode 100644 index 000000000..67b5ee456 --- /dev/null +++ b/src/HttpClient/middlewares/metrics.test.ts @@ -0,0 +1,335 @@ +// Mock @vtex/diagnostics-nodejs before any imports +jest.mock('@vtex/diagnostics-nodejs', () => ({ + Types: {}, + getMetricClient: jest.fn(), + getLogger: jest.fn(), +})) + +jest.mock('../../service/metrics/client', () => ({ + getMetricClient: jest.fn(), +})) + +jest.mock('../../errors/RequestCancelledError', () => ({ + RequestCancelledError: class RequestCancelledError extends Error { + constructor(message: string) { + super(message) + this.name = 'RequestCancelledError' + } + }, +})) + +import { DiagnosticsMetrics } from '../../metrics/DiagnosticsMetrics' +import { MetricsAccumulator } from '../../metrics/MetricsAccumulator' +import { metricsMiddleware } from './metrics' +import { MiddlewareContext } from '../typings' + +describe('metricsMiddleware', () => { + let mockMetrics: jest.Mocked + let mockDiagnosticsMetrics: jest.Mocked + let mockNext: jest.Mock + let mockCtx: MiddlewareContext + + beforeEach(() => { + // Mock MetricsAccumulator + mockMetrics = { + batch: jest.fn(), + } as any + + // Mock DiagnosticsMetrics + mockDiagnosticsMetrics = { + recordLatency: jest.fn(), + incrementCounter: jest.fn(), + setGauge: jest.fn(), + } as any + + // Set up global + global.diagnosticsMetrics = mockDiagnosticsMetrics + + // Mock next function + mockNext = jest.fn().mockResolvedValue(undefined) + + // Mock context + mockCtx = { + config: { + metric: 'test-client', + retryCount: 0, + }, + response: { + status: 200, + }, + cacheHit: undefined, + inflightHit: undefined, + memoizedHit: undefined, + } as any + }) + + afterEach(() => { + jest.clearAllMocks() + delete (global as any).diagnosticsMetrics + }) + + describe('successful requests', () => { + it('should record metrics for successful request with no cache', async () => { + const middleware = metricsMiddleware({ metrics: mockMetrics, name: 'test' }) + + await middleware(mockCtx, mockNext) + + // Legacy metrics + const batchCall = mockMetrics.batch.mock.calls[0] + expect(batchCall[0]).toBe('http-client-test-client') + expect(Array.isArray(batchCall[1])).toBe(true) // hrtime tuple + expect(batchCall[2]).toMatchObject({ + success: 1, + 'success-miss': 1, + }) + + // Diagnostics metrics + const latencyCall = mockDiagnosticsMetrics.recordLatency.mock.calls[0] + expect(Array.isArray(latencyCall[0])).toBe(true) // hrtime tuple + expect(latencyCall[1]).toMatchObject({ + component: 'http-client', + client_metric: 'test-client', + status_code: 200, + status: 'success', + cache_state: 'miss', + }) + + // Main request counter with status as attribute + expect(mockDiagnosticsMetrics.incrementCounter).toHaveBeenCalledWith( + 'http_client_requests_total', + 1, + expect.objectContaining({ + component: 'http-client', + client_metric: 'test-client', + status_code: 200, + status: 'success', + }) + ) + + // Cache counter with cache_state as attribute + expect(mockDiagnosticsMetrics.incrementCounter).toHaveBeenCalledWith( + 'http_client_cache_total', + 1, + expect.objectContaining({ + component: 'http-client', + client_metric: 'test-client', + status: 'success', + cache_state: 'miss', + }) + ) + }) + + it('should record metrics with cache hit', async () => { + mockCtx.cacheHit = { revalidated: 1 } + + const middleware = metricsMiddleware({ metrics: mockMetrics, name: 'test' }) + + await middleware(mockCtx, mockNext) + + const latencyCall = mockDiagnosticsMetrics.recordLatency.mock.calls[0] + expect(latencyCall[1]).toMatchObject({ + cache_state: 'hit', + }) + + // Should have cache counter with cache_state attribute + expect(mockDiagnosticsMetrics.incrementCounter).toHaveBeenCalledWith( + 'http_client_cache_total', + 1, + expect.objectContaining({ + component: 'http-client', + client_metric: 'test-client', + cache_state: 'hit', + }) + ) + }) + + it('should record metrics with inflight hit', async () => { + mockCtx.inflightHit = true + + const middleware = metricsMiddleware({ metrics: mockMetrics, name: 'test' }) + + await middleware(mockCtx, mockNext) + + const latencyCall = mockDiagnosticsMetrics.recordLatency.mock.calls[0] + expect(latencyCall[1]).toMatchObject({ + cache_state: 'inflight', + }) + }) + + it('should record metrics with memoized hit', async () => { + mockCtx.memoizedHit = true + + const middleware = metricsMiddleware({ metrics: mockMetrics, name: 'test' }) + + await middleware(mockCtx, mockNext) + + const latencyCall = mockDiagnosticsMetrics.recordLatency.mock.calls[0] + expect(latencyCall[1]).toMatchObject({ + cache_state: 'memoized', + }) + }) + + it('should include retry count in attributes when retries occurred', async () => { + mockCtx.config.retryCount = 2 + + const middleware = metricsMiddleware({ metrics: mockMetrics, name: 'test' }) + + await middleware(mockCtx, mockNext) + + // Histogram should not include retry_count (only in counter) + const latencyCall = mockDiagnosticsMetrics.recordLatency.mock.calls[0] + expect(latencyCall[1]).toMatchObject({ + status: 'success', + cache_state: 'miss', + }) + + // Should have retry counter with retry_count attribute + expect(mockDiagnosticsMetrics.incrementCounter).toHaveBeenCalledWith( + 'http_client_requests_retried_total', + 1, + expect.objectContaining({ + component: 'http-client', + client_metric: 'test-client', + status: 'success', + status_code: 200, + retry_count: 2, // Number, not string + }) + ) + }) + }) + + describe('error handling', () => { + it('should record metrics for timeout errors', async () => { + mockNext.mockRejectedValueOnce({ + response: { + data: { code: 'ProxyTimeout' }, // TIMEOUT_CODE + }, + }) + + const middleware = metricsMiddleware({ metrics: mockMetrics, name: 'test' }) + + await expect(middleware(mockCtx, mockNext)).rejects.toMatchObject({ + response: expect.any(Object), + }) + + const latencyCall = mockDiagnosticsMetrics.recordLatency.mock.calls[0] + expect(latencyCall[1]).toMatchObject({ + status: 'timeout', + }) + }) + + it('should record metrics for aborted requests', async () => { + mockNext.mockRejectedValueOnce({ + code: 'ECONNABORTED', + }) + + const middleware = metricsMiddleware({ metrics: mockMetrics, name: 'test' }) + + await expect(middleware(mockCtx, mockNext)).rejects.toMatchObject({ + code: 'ECONNABORTED', + }) + + const latencyCall = mockDiagnosticsMetrics.recordLatency.mock.calls[0] + expect(latencyCall[1]).toMatchObject({ + status: 'aborted', + }) + }) + + it('should record metrics for cancelled requests', async () => { + mockNext.mockRejectedValueOnce({ + message: 'Request cancelled', + }) + + const middleware = metricsMiddleware({ metrics: mockMetrics, name: 'test' }) + + await expect(middleware(mockCtx, mockNext)).rejects.toThrow('Request cancelled') + + const latencyCall = mockDiagnosticsMetrics.recordLatency.mock.calls[0] + expect(latencyCall[1]).toMatchObject({ + status: 'cancelled', + }) + }) + + it('should record metrics for HTTP error responses', async () => { + mockNext.mockRejectedValueOnce({ + response: { + status: 500, + }, + }) + + const middleware = metricsMiddleware({ metrics: mockMetrics, name: 'test' }) + + await expect(middleware(mockCtx, mockNext)).rejects.toMatchObject({ + response: { status: 500 }, + }) + + const latencyCall = mockDiagnosticsMetrics.recordLatency.mock.calls[0] + expect(latencyCall[1]).toMatchObject({ + status: 'error', + }) + }) + + it('should record metrics for generic errors', async () => { + mockNext.mockRejectedValueOnce(new Error('Generic error')) + + const middleware = metricsMiddleware({ metrics: mockMetrics, name: 'test' }) + + await expect(middleware(mockCtx, mockNext)).rejects.toThrow('Generic error') + + const latencyCall = mockDiagnosticsMetrics.recordLatency.mock.calls[0] + expect(latencyCall[1]).toMatchObject({ + status: 'error', + }) + }) + }) + + describe('backward compatibility', () => { + it('should maintain legacy metrics when config.metric is set', async () => { + const middleware = metricsMiddleware({ metrics: mockMetrics, name: 'test' }) + + await middleware(mockCtx, mockNext) + + // Verify legacy metrics still called + expect(mockMetrics.batch).toHaveBeenCalledTimes(1) + const batchCall = mockMetrics.batch.mock.calls[0] + expect(batchCall[0]).toBe('http-client-test-client') + expect(Array.isArray(batchCall[1])).toBe(true) + expect(batchCall[2]).toBeTruthy() + }) + + it('should not record metrics when config.metric is not set', async () => { + mockCtx.config.metric = undefined + + const middleware = metricsMiddleware({ metrics: mockMetrics, name: 'test' }) + + await middleware(mockCtx, mockNext) + + expect(mockMetrics.batch).not.toHaveBeenCalled() + expect(mockDiagnosticsMetrics.recordLatency).not.toHaveBeenCalled() + expect(mockDiagnosticsMetrics.incrementCounter).not.toHaveBeenCalled() + }) + }) + + describe('graceful degradation', () => { + it('should work without global.diagnosticsMetrics', async () => { + delete (global as any).diagnosticsMetrics + + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation() + + const middleware = metricsMiddleware({ metrics: mockMetrics, name: 'test' }) + + await middleware(mockCtx, mockNext) + + // Legacy metrics still work + expect(mockMetrics.batch).toHaveBeenCalled() + + // Warning logged + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'DiagnosticsMetrics not available. HTTP client metrics not reported.' + ) + + consoleWarnSpy.mockRestore() + }) + }) +}) + diff --git a/src/HttpClient/middlewares/metrics.ts b/src/HttpClient/middlewares/metrics.ts index 2e8b3f349..41e7506ba 100644 --- a/src/HttpClient/middlewares/metrics.ts +++ b/src/HttpClient/middlewares/metrics.ts @@ -1,5 +1,7 @@ +import { Attributes } from '@opentelemetry/api' import { compose, forEach, path, reduce, replace, split } from 'ramda' +import { ACCOUNT_HEADER, AttributeKeys } from '../../constants' import { RequestCancelledError } from '../../errors/RequestCancelledError' import { MetricsAccumulator } from '../../metrics/MetricsAccumulator' import { @@ -14,6 +16,8 @@ import { TIMEOUT_CODE } from '../../utils/retry' import { statusLabel } from '../../utils/status' import { MiddlewareContext } from '../typings' +const DEFAULT_ACCOUNT = 'unknown' + interface MetricsOpts { metrics?: MetricsAccumulator serverTiming?: Record @@ -75,35 +79,84 @@ export const metricsMiddleware = ({metrics, serverTiming, name}: MetricsOpts) => Object.assign(extensions, {[status]: 1}) + // Determine cache state for diagnostics metrics + let cacheState = 'none' + if (ctx.cacheHit) { Object.assign(extensions, ctx.cacheHit, {[`${status}-hit`]: 1}) + cacheState = 'hit' } else if (!ctx.inflightHit && !ctx.memoizedHit) { // Lets us know how many calls passed through to origin Object.assign(extensions, {[`${status}-miss`]: 1}) + cacheState = 'miss' } if (ctx.inflightHit) { Object.assign(extensions, {[`${status}-inflight`]: 1}) + cacheState = 'inflight' } if (ctx.memoizedHit) { Object.assign(extensions, {[`${status}-memoized`]: 1}) + cacheState = 'memoized' } - if (ctx.config.retryCount) { - const retryCount = ctx.config.retryCount + const retryCount = ctx.config.retryCount || 0 - if (retryCount > 0) { - extensions[`retry-${status}-${retryCount}`] = 1 - } + if (retryCount > 0) { + extensions[`retry-${status}-${retryCount}`] = 1 } const end = status === 'success' && !ctx.cacheHit && !ctx.inflightHit && !ctx.memoizedHit ? process.hrtime(start) : undefined + // Legacy metrics (backward compatibility) metrics.batch(label, end, extensions) + // New diagnostics metrics with stable names and attributes + if (global.diagnosticsMetrics) { + const elapsed = process.hrtime(start) + const rawStatusCode = ctx.response?.status || errorStatus + // Extract account from request headers, fallback to default + const account = (ctx.config.headers?.[ACCOUNT_HEADER] as string) || DEFAULT_ACCOUNT + const baseAttributes: Attributes = { + [AttributeKeys.VTEX_ACCOUNT_NAME]: account, + component: 'http-client', + client_metric: ctx.config.metric, + status_code: rawStatusCode, + status, + } + + // Record latency histogram with all context + global.diagnosticsMetrics.recordLatency(elapsed, { + ...baseAttributes, + cache_state: cacheState, + }) + + // Increment counters for different event types (replaces extensions behavior) + // Main request counter with status as attribute + global.diagnosticsMetrics.incrementCounter('http_client_requests_total', 1, baseAttributes) + + // Cache counter with cache_state as attribute (replaces extensions like 'success-hit', 'error-miss') + if (cacheState !== 'none') { + global.diagnosticsMetrics.incrementCounter('http_client_cache_total', 1, { + ...baseAttributes, + cache_state: cacheState, + }) + } + + // Retry counter (replaces 'retry-{status}-{count}' extensions) + if (retryCount > 0) { + global.diagnosticsMetrics.incrementCounter('http_client_requests_retried_total', 1, { + ...baseAttributes, + retry_count: retryCount, + }) + } + } else { + console.warn('DiagnosticsMetrics not available. HTTP client metrics not reported.') + } + if (ctx.config.verbose) { console.log(`VERBOSE: ${name}.${ctx.config.label}`, { ...extensions, diff --git a/src/HttpClient/middlewares/request/HttpAgentSingleton.test.ts b/src/HttpClient/middlewares/request/HttpAgentSingleton.test.ts new file mode 100644 index 000000000..172a26aad --- /dev/null +++ b/src/HttpClient/middlewares/request/HttpAgentSingleton.test.ts @@ -0,0 +1,102 @@ +import { HttpAgentSingleton } from './HttpAgentSingleton' + +// Mock the createHttpAgent function (external dependency) +jest.mock('../../../HttpClient/agents', () => ({ + createHttpAgent: jest.fn(() => ({ + sockets: {}, + freeSockets: {}, + requests: {}, + })), +})) + +describe('HttpAgentSingleton', () => { + let recordedGaugeCalls: Map> + + beforeEach(() => { + // Reset call tracking + recordedGaugeCalls = new Map() + + // Create a minimal stub that tracks gauge calls (not mocking DiagnosticsMetrics class) + global.diagnosticsMetrics = { + setGauge: (name: string, value: number, attributes?: any) => { + if (!recordedGaugeCalls.has(name)) { + recordedGaugeCalls.set(name, []) + } + recordedGaugeCalls.get(name)!.push({ value, attributes }) + }, + } as any + + // Reset the agent's internal state + const agent = HttpAgentSingleton.getHttpAgent() + ;(agent as any).sockets = {} + ;(agent as any).freeSockets = {} + ;(agent as any).requests = {} + }) + + afterEach(() => { + // Clean up global + delete (global as any).diagnosticsMetrics + }) + + describe('httpAgentStats', () => { + it('should return current HTTP agent statistics', () => { + const agent = HttpAgentSingleton.getHttpAgent() + + // Mock some socket data + ;(agent as any).sockets = { 'host1:80': [1, 2], 'host2:443': [1] } + ;(agent as any).freeSockets = { 'host1:80': [1] } + ;(agent as any).requests = { 'host1:80': [1, 2, 3] } + + const stats = HttpAgentSingleton.httpAgentStats() + + expect(stats).toEqual({ + sockets: 3, + freeSockets: 1, + pendingRequests: 3, + }) + }) + + it('should return zero counts for empty agent', () => { + const stats = HttpAgentSingleton.httpAgentStats() + + expect(stats).toEqual({ + sockets: 0, + freeSockets: 0, + pendingRequests: 0, + }) + }) + }) + + describe('updateHttpAgentMetrics', () => { + it('should report HTTP agent stats as gauges to diagnostics metrics', () => { + const agent = HttpAgentSingleton.getHttpAgent() + + // Mock some socket data + ;(agent as any).sockets = { 'host1:80': [1, 2] } + ;(agent as any).freeSockets = { 'host1:80': [1] } + ;(agent as any).requests = { 'host1:80': [1, 2, 3] } + + HttpAgentSingleton.updateHttpAgentMetrics() + + expect(recordedGaugeCalls.get('http_agent_sockets_current')).toEqual([{ value: 2, attributes: {} }]) + expect(recordedGaugeCalls.get('http_agent_free_sockets_current')).toEqual([{ value: 1, attributes: {} }]) + expect(recordedGaugeCalls.get('http_agent_pending_requests_current')).toEqual([{ value: 3, attributes: {} }]) + }) + + it('should handle missing global.diagnosticsMetrics gracefully', () => { + delete (global as any).diagnosticsMetrics + + // Should not throw + expect(() => HttpAgentSingleton.updateHttpAgentMetrics()).not.toThrow() + }) + + it('should report zero values when agent has no active connections', () => { + HttpAgentSingleton.updateHttpAgentMetrics() + + expect(recordedGaugeCalls.get('http_agent_sockets_current')).toEqual([{ value: 0, attributes: {} }]) + expect(recordedGaugeCalls.get('http_agent_free_sockets_current')).toEqual([{ value: 0, attributes: {} }]) + expect(recordedGaugeCalls.get('http_agent_pending_requests_current')).toEqual([{ value: 0, attributes: {} }]) + }) + }) +}) + diff --git a/src/HttpClient/middlewares/request/HttpAgentSingleton.ts b/src/HttpClient/middlewares/request/HttpAgentSingleton.ts index d51f475cb..705ee765a 100644 --- a/src/HttpClient/middlewares/request/HttpAgentSingleton.ts +++ b/src/HttpClient/middlewares/request/HttpAgentSingleton.ts @@ -21,6 +21,24 @@ export class HttpAgentSingleton { sockets, } } + /** + * Update HTTP agent statistics as diagnostics metrics (gauges). + * This method should be called periodically to export current HTTP agent state. + */ + public static updateHttpAgentMetrics() { + if (!global.diagnosticsMetrics) { + console.warn('DiagnosticsMetrics not available. HTTP agent metrics not reported.') + return + } + + const stats = HttpAgentSingleton.httpAgentStats() + + // Report HTTP agent stats as gauges (current state) + global.diagnosticsMetrics.setGauge('http_agent_sockets_current', stats.sockets, {}) + global.diagnosticsMetrics.setGauge('http_agent_free_sockets_current', stats.freeSockets, {}) + global.diagnosticsMetrics.setGauge('http_agent_pending_requests_current', stats.pendingRequests, {}) + } + private static httpAgent: HttpAgent private static count(obj: { [key: string]: any[] }) { diff --git a/src/service/index.test.ts b/src/service/index.test.ts new file mode 100644 index 000000000..19429eb36 --- /dev/null +++ b/src/service/index.test.ts @@ -0,0 +1,56 @@ +const mockInitializeTelemetry = jest.fn() +const mockGetServiceJSON = jest.fn() +const mockStartMaster = jest.fn() +const mockStartWorker = jest.fn() + +jest.mock('./telemetry', () => ({ + initializeTelemetry: mockInitializeTelemetry, +})) + +jest.mock('./loaders', () => ({ + getServiceJSON: mockGetServiceJSON, +})) + +jest.mock('./master', () => ({ + startMaster: mockStartMaster, +})) + +jest.mock('./worker', () => ({ + startWorker: mockStartWorker, +})) + +jest.mock('cluster', () => ({ + isMaster: true, +})) + +describe('startApp', () => { + beforeEach(() => { + jest.clearAllMocks() + mockInitializeTelemetry.mockResolvedValue({}) + mockGetServiceJSON.mockReturnValue({}) + }) + + it('initializes telemetry before starting the master/worker process', async () => { + const { startApp } = require('./index') + + await startApp() + + // DiagnosticsMetrics's own constructor also triggers telemetry initialization via + // getMetricClient(); the real TelemetryClientSingleton dedupes concurrent/repeat + // calls, so we only assert it was called, not an exact count. + expect(mockInitializeTelemetry).toHaveBeenCalled() + expect(mockStartMaster).toHaveBeenCalledTimes(1) + expect(mockInitializeTelemetry.mock.invocationCallOrder[0]).toBeLessThan( + mockStartMaster.mock.invocationCallOrder[0] + ) + }) + + it('exposes global.diagnosticsMetrics after startup', async () => { + const { startApp } = require('./index') + + await startApp() + + expect(global.diagnosticsMetrics).toBeDefined() + expect(typeof global.diagnosticsMetrics.recordLatency).toBe('function') + }) +}) diff --git a/src/service/index.ts b/src/service/index.ts index 190150df7..b26f1ace0 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -1,12 +1,18 @@ import cluster from 'cluster' import { HTTP_SERVER_PORT } from '../constants' +import { DiagnosticsMetrics } from '../metrics/DiagnosticsMetrics' import { getServiceJSON } from './loaders' import { LogLevel, logOnceToDevConsole } from './logger' import { startMaster } from './master' +import { initializeTelemetry } from './telemetry' import { startWorker } from './worker' -export const startApp = () => { +export const startApp = async () => { + await initializeTelemetry() + + global.diagnosticsMetrics = new DiagnosticsMetrics() + const serviceJSON = getServiceJSON() try { // if it is a master process then call setting up worker process @@ -24,3 +30,10 @@ export const startApp = () => { export { appPath } from './loaders' +declare global { + namespace NodeJS { + interface Global { + diagnosticsMetrics: DiagnosticsMetrics + } + } +} diff --git a/src/service/worker/runtime/__tests__/statusTrack.test.ts b/src/service/worker/runtime/__tests__/statusTrack.test.ts index d249c3a8f..1b31c674a 100644 --- a/src/service/worker/runtime/__tests__/statusTrack.test.ts +++ b/src/service/worker/runtime/__tests__/statusTrack.test.ts @@ -1,4 +1,12 @@ -import { statusTrackHandler } from '../statusTrack' +const mockUpdateHttpAgentMetrics = jest.fn() + +jest.mock('../../../../HttpClient/middlewares/request/HttpAgentSingleton', () => ({ + HttpAgentSingleton: { + updateHttpAgentMetrics: mockUpdateHttpAgentMetrics, + }, +})) + +import { statusTrackHandler, trackStatus } from '../statusTrack' import { ServiceContext } from '../typings' describe('statusTrackHandler', () => { @@ -27,3 +35,17 @@ describe('statusTrackHandler', () => { expect(ctx.requestHandlerName).toBe('builtin:status-track') }) }) + +describe('trackStatus', () => { + beforeEach(() => { + jest.clearAllMocks() + global.metrics = { statusTrack: jest.fn() } as any + }) + + it('updates HTTP agent diagnostics metrics alongside the legacy flush', () => { + trackStatus() + + expect(mockUpdateHttpAgentMetrics).toHaveBeenCalledTimes(1) + expect(global.metrics.statusTrack).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/service/worker/runtime/graphql/schema/schemaDirectives/Metric.test.ts b/src/service/worker/runtime/graphql/schema/schemaDirectives/Metric.test.ts new file mode 100644 index 000000000..086804bc1 --- /dev/null +++ b/src/service/worker/runtime/graphql/schema/schemaDirectives/Metric.test.ts @@ -0,0 +1,341 @@ +// Mock the diagnostics-nodejs module to avoid deep import issues (must be before imports) +jest.mock('@vtex/diagnostics-nodejs', () => ({ + Types: {}, +})) + +import { Metric } from './Metric' + +describe('Metric.visitFieldDefinition (real class, not the reimplemented resolver below)', () => { + let mockDiagnosticsMetrics: any + + beforeEach(() => { + global.metrics = { batch: jest.fn() } as any + mockDiagnosticsMetrics = { incrementCounter: jest.fn(), recordLatency: jest.fn() } + global.diagnosticsMetrics = mockDiagnosticsMetrics + }) + + afterEach(() => { + delete (global as any).metrics + delete (global as any).diagnosticsMetrics + jest.clearAllMocks() + }) + + const buildField = (resolve: (...args: any[]) => any) => ({ + name: 'testField', + resolve, + }) + + const buildMetric = (args: Record = {}) => { + const metric = Object.create(Metric.prototype) as Metric + ;(metric as any).args = args + return metric + } + + it('records diagnostics metrics for the real directive on success', async () => { + const field: any = buildField(async () => 'field-result') + buildMetric().visitFieldDefinition(field) + + const ctx: any = { graphql: {}, vtex: { account: 'testaccount' } } + const result = await field.resolve({}, {}, ctx, {}) + + expect(result).toBe('field-result') + expect(ctx.graphql.status).toBe('success') + + const [latencyArg, latencyAttrs] = mockDiagnosticsMetrics.recordLatency.mock.calls[0] + expect(Array.isArray(latencyArg)).toBe(true) + expect(latencyAttrs).toMatchObject({ component: 'graphql', status: 'success' }) + + const [counterName, counterValue, counterAttrs] = mockDiagnosticsMetrics.incrementCounter.mock.calls[0] + expect(counterName).toBe('graphql_field_requests_total') + expect(counterValue).toBe(1) + expect(counterAttrs).toMatchObject({ component: 'graphql', status: 'success' }) + }) + + it('records diagnostics metrics for the real directive on failure and still throws', async () => { + const failure = new Error('boom') + const field: any = buildField(async () => { throw failure }) + buildMetric().visitFieldDefinition(field) + + const ctx: any = { graphql: {}, vtex: { account: 'testaccount' } } + await expect(field.resolve({}, {}, ctx, {})).rejects.toBe(failure) + + expect(ctx.graphql.status).toBe('error') + const [latencyArg, latencyAttrs] = mockDiagnosticsMetrics.recordLatency.mock.calls[0] + expect(Array.isArray(latencyArg)).toBe(true) + expect(latencyAttrs).toMatchObject({ status: 'error' }) + }) + + it('degrades gracefully when global.diagnosticsMetrics is unavailable', async () => { + delete (global as any).diagnosticsMetrics + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation() + + const field: any = buildField(async () => 'field-result') + buildMetric().visitFieldDefinition(field) + + const ctx: any = { graphql: {} } + const result = await field.resolve({}, {}, ctx, {}) + + expect(result).toBe('field-result') + expect(consoleWarnSpy).toHaveBeenCalledWith('DiagnosticsMetrics not available. GraphQL field metrics not reported.') + expect(global.metrics.batch).toHaveBeenCalled() + + consoleWarnSpy.mockRestore() + }) +}) + +describe('Metric Schema Directive', () => { + let mockMetricsAccumulator: any + let mockDiagnosticsMetrics: any + let mockContext: any + + beforeEach(() => { + // Reset global.metrics and global.diagnosticsMetrics + mockMetricsAccumulator = { + batch: jest.fn(), + } + mockDiagnosticsMetrics = { + incrementCounter: jest.fn(), + recordLatency: jest.fn(), + } + ;(global as any).metrics = mockMetricsAccumulator + ;(global as any).diagnosticsMetrics = mockDiagnosticsMetrics + ;(global as any).APP = { NAME: 'vtex.render-server@1.x' } + + // Create a mock context + mockContext = { + graphql: { + status: undefined, + }, + } + }) + + afterEach(() => { + delete (global as any).metrics + delete (global as any).diagnosticsMetrics + delete (global as any).APP + jest.clearAllMocks() + }) + + it('should record successful field resolution metrics', async () => { + const mockResolver = jest.fn().mockResolvedValue('test-result') + + // Simulate what the Metric directive does + const wrappedResolver = async (root: any, args: any, ctx: any, info: any) => { + let failedToResolve = false + let resolverResult: any = null + let ellapsed: [number, number] = [0, 0] + + try { + const start = process.hrtime() + resolverResult = await mockResolver(root, args, ctx, info) + ellapsed = process.hrtime(start) + } catch (error) { + resolverResult = error + failedToResolve = true + } + + const status = failedToResolve ? 'error' : 'success' + ctx.graphql.status = status + const name = 'vtex.render-server@1.x-testField' + + const payload = { + [status]: 1, + } + + // Legacy metrics + ;(global as any).metrics.batch(`graphql-metric-${name}`, failedToResolve ? undefined : ellapsed, payload) + + // New diagnostics metrics + if ((global as any).diagnosticsMetrics) { + const attributes = { + component: 'graphql', + field_name: name, + status, + } + + ;(global as any).diagnosticsMetrics.recordLatency(ellapsed, attributes) + ;(global as any).diagnosticsMetrics.incrementCounter('graphql_field_requests_total', 1, attributes) + } + + if (failedToResolve) { + throw resolverResult + } + + return resolverResult + } + + // Execute the wrapped resolver + const result = await wrappedResolver({}, {}, mockContext, {}) + + expect(result).toBe('test-result') + expect(mockContext.graphql.status).toBe('success') + + // Legacy metrics + const batchCall = mockMetricsAccumulator.batch.mock.calls[0] + expect(batchCall[0]).toBe('graphql-metric-vtex.render-server@1.x-testField') + expect(Array.isArray(batchCall[1])).toBe(true) // hrtime is an array + expect(batchCall[2]).toEqual({ success: 1 }) + + // Diagnostics metrics + const recordLatencyCall = mockDiagnosticsMetrics.recordLatency.mock.calls[0] + expect(Array.isArray(recordLatencyCall[0])).toBe(true) // hrtime is an array + expect(recordLatencyCall[1]).toEqual({ + component: 'graphql', + field_name: 'vtex.render-server@1.x-testField', + status: 'success', + }) + + expect(mockDiagnosticsMetrics.incrementCounter).toHaveBeenCalledWith( + 'graphql_field_requests_total', + 1, + { + component: 'graphql', + field_name: 'vtex.render-server@1.x-testField', + status: 'success', + } + ) + }) + + it('should record failed field resolution metrics', async () => { + const testError = new Error('Test error') + const mockResolver = jest.fn().mockRejectedValue(testError) + + // Simulate what the Metric directive does + const wrappedResolver = async (root: any, args: any, ctx: any, info: any) => { + let failedToResolve = false + let result: any = null + let ellapsed: [number, number] = [0, 0] + + try { + const start = process.hrtime() + result = await mockResolver(root, args, ctx, info) + ellapsed = process.hrtime(start) + } catch (error) { + result = error + failedToResolve = true + } + + const status = failedToResolve ? 'error' : 'success' + ctx.graphql.status = status + const name = 'vtex.render-server@1.x-testField' + + const payload = { + [status]: 1, + } + + // Legacy metrics + ;(global as any).metrics.batch(`graphql-metric-${name}`, failedToResolve ? undefined : ellapsed, payload) + + // New diagnostics metrics + if ((global as any).diagnosticsMetrics) { + const attributes = { + component: 'graphql', + field_name: name, + status, + } + + ;(global as any).diagnosticsMetrics.recordLatency(ellapsed, attributes) + ;(global as any).diagnosticsMetrics.incrementCounter('graphql_field_requests_total', 1, attributes) + } + + if (failedToResolve) { + throw result + } + + return result + } + + // Execute the wrapped resolver and expect it to throw + await expect(wrappedResolver({}, {}, mockContext, {})).rejects.toThrow('Test error') + + expect(mockContext.graphql.status).toBe('error') + + // Legacy metrics (no latency on error) + expect(mockMetricsAccumulator.batch).toHaveBeenCalledWith( + 'graphql-metric-vtex.render-server@1.x-testField', + undefined, + { error: 1 } + ) + + // Diagnostics metrics (record latency even on error) + const recordLatencyCall = mockDiagnosticsMetrics.recordLatency.mock.calls[0] + expect(Array.isArray(recordLatencyCall[0])).toBe(true) // hrtime is an array + expect(recordLatencyCall[1]).toEqual({ + component: 'graphql', + field_name: 'vtex.render-server@1.x-testField', + status: 'error', + }) + + expect(mockDiagnosticsMetrics.incrementCounter).toHaveBeenCalledWith( + 'graphql_field_requests_total', + 1, + { + component: 'graphql', + field_name: 'vtex.render-server@1.x-testField', + status: 'error', + } + ) + }) + + it('should warn when DiagnosticsMetrics is not available', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation() + delete (global as any).diagnosticsMetrics + + const mockResolver = jest.fn().mockResolvedValue('test-result') + + // Simulate what the Metric directive does (without diagnostics) + const wrappedResolver = async (root: any, args: any, ctx: any, info: any) => { + let failedToResolve = false + let result: any = null + let ellapsed: [number, number] = [0, 0] + + try { + const start = process.hrtime() + result = await mockResolver(root, args, ctx, info) + ellapsed = process.hrtime(start) + } catch (error) { + result = error + failedToResolve = true + } + + const status = failedToResolve ? 'error' : 'success' + ctx.graphql.status = status + const name = 'vtex.render-server@1.x-testField' + + const payload = { + [status]: 1, + } + + // Legacy metrics + ;(global as any).metrics.batch(`graphql-metric-${name}`, failedToResolve ? undefined : ellapsed, payload) + + // New diagnostics metrics + if ((global as any).diagnosticsMetrics) { + const attributes = { + component: 'graphql', + field_name: name, + status, + } + + ;(global as any).diagnosticsMetrics.recordLatency(ellapsed, attributes) + ;(global as any).diagnosticsMetrics.incrementCounter('graphql_field_requests_total', 1, attributes) + } else { + console.warn('DiagnosticsMetrics not available. GraphQL field metrics not reported.') + } + + if (failedToResolve) { + throw result + } + + return result + } + + // Execute the wrapped resolver + await wrappedResolver({}, {}, mockContext, {}) + + expect(consoleWarnSpy).toHaveBeenCalledWith('DiagnosticsMetrics not available. GraphQL field metrics not reported.') + expect(mockMetricsAccumulator.batch).toHaveBeenCalled() // Legacy still works + + consoleWarnSpy.mockRestore() + }) +}) diff --git a/src/service/worker/runtime/graphql/schema/schemaDirectives/Metric.ts b/src/service/worker/runtime/graphql/schema/schemaDirectives/Metric.ts index eb386e426..c6e9ac460 100644 --- a/src/service/worker/runtime/graphql/schema/schemaDirectives/Metric.ts +++ b/src/service/worker/runtime/graphql/schema/schemaDirectives/Metric.ts @@ -1,6 +1,7 @@ +import { Attributes } from '@opentelemetry/api' import { defaultFieldResolver, GraphQLField } from 'graphql' import { SchemaDirectiveVisitor } from 'graphql-tools' -import { APP } from '../../../../../..' +import { APP, AttributeKeys } from '../../../../../..' import { GraphQLServiceContext } from '../../typings' interface Args { @@ -26,14 +27,34 @@ export class Metric extends SchemaDirectiveVisitor { failedToResolve = true } - ctx.graphql.status = failedToResolve ? 'error' : 'success' + const status = failedToResolve ? 'error' : 'success' + ctx.graphql.status = status const payload = { - [ctx.graphql.status]: 1, + [status]: 1, } + // Legacy metrics (backward compatibility) metrics.batch(`graphql-metric-${name}`, failedToResolve ? undefined : ellapsed, payload) + // New diagnostics metrics with stable names and attributes + if (global.diagnosticsMetrics) { + const attributes: Attributes = { + [AttributeKeys.VTEX_ACCOUNT_NAME]: ctx.vtex.account, + component: 'graphql', + field_name: name, + status, + } + + // Record latency histogram (record all requests, not just successful ones) + global.diagnosticsMetrics.recordLatency(ellapsed, attributes) + + // Increment counter (status is an attribute, not in metric name) + global.diagnosticsMetrics.incrementCounter('graphql_field_requests_total', 1, attributes) + } else { + console.warn('DiagnosticsMetrics not available. GraphQL field metrics not reported.') + } + if (failedToResolve) { throw result } diff --git a/src/service/worker/runtime/http/middlewares/requestStats.test.ts b/src/service/worker/runtime/http/middlewares/requestStats.test.ts new file mode 100644 index 000000000..b399248b5 --- /dev/null +++ b/src/service/worker/runtime/http/middlewares/requestStats.test.ts @@ -0,0 +1,194 @@ +import { EventEmitter } from 'events' +import { DiagnosticsMetrics } from '../../../../../metrics/DiagnosticsMetrics' +import { incomingRequestStats, trackIncomingRequestStats } from './requestStats' + +describe('requestStats', () => { + let mockDiagnosticsMetrics: jest.Mocked + + beforeEach(() => { + // Create mock DiagnosticsMetrics instance + mockDiagnosticsMetrics = { + incrementCounter: jest.fn(), + recordLatency: jest.fn(), + setGauge: jest.fn(), + } as any + + // Set global.diagnosticsMetrics for the tests + global.diagnosticsMetrics = mockDiagnosticsMetrics + + // Clear stats before each test + incomingRequestStats.clear() + }) + + afterEach(() => { + jest.clearAllMocks() + delete (global as any).diagnosticsMetrics + }) + + describe('IncomingRequestStats', () => { + it('should track total requests', () => { + incomingRequestStats.total++ + incomingRequestStats.total++ + + const stats = incomingRequestStats.get() + expect(stats.total).toBe(2) + }) + + it('should track aborted requests', () => { + incomingRequestStats.aborted++ + incomingRequestStats.aborted++ + + const stats = incomingRequestStats.get() + expect(stats.aborted).toBe(2) + }) + + it('should track closed requests', () => { + incomingRequestStats.closed++ + + const stats = incomingRequestStats.get() + expect(stats.closed).toBe(1) + }) + + it('should clear all stats', () => { + incomingRequestStats.total = 5 + incomingRequestStats.aborted = 3 + incomingRequestStats.closed = 2 + + incomingRequestStats.clear() + + const stats = incomingRequestStats.get() + expect(stats).toEqual({ + aborted: 0, + closed: 0, + total: 0, + }) + }) + }) + + describe('trackIncomingRequestStats', () => { + let mockCtx: any + let mockRequest: EventEmitter + let mockNext: jest.Mock + + beforeEach(() => { + mockRequest = new EventEmitter() + mockNext = jest.fn().mockResolvedValue(undefined) + mockCtx = { + req: mockRequest, + status: 200, + vtex: { + cancellation: { + cancelable: true, + cancelled: false, + source: { cancel: jest.fn() }, + }, + route: { + id: 'test-route', + type: 'public', + }, + }, + } + }) + + it('should increment total requests counter and report to diagnostics metrics', async () => { + await trackIncomingRequestStats(mockCtx, mockNext) + + expect(incomingRequestStats.get().total).toBe(1) + expect(mockDiagnosticsMetrics.incrementCounter).toHaveBeenCalledWith( + 'http_server_requests_total', + 1, + { + route_id: 'test-route', + route_type: 'public', + status_code: 200, + } + ) + }) + + it('should call next middleware', async () => { + await trackIncomingRequestStats(mockCtx, mockNext) + + expect(mockNext).toHaveBeenCalledTimes(1) + }) + + it('should increment closed counter when request closes and report to diagnostics', async () => { + await trackIncomingRequestStats(mockCtx, mockNext) + + mockRequest.emit('close') + + expect(incomingRequestStats.get().closed).toBe(1) + expect(mockDiagnosticsMetrics.incrementCounter).toHaveBeenCalledWith( + 'http_server_requests_closed_total', + 1, + { + route_id: 'test-route', + route_type: 'public', + status_code: 200, + } + ) + }) + + it('should increment aborted counter when request aborts and report to diagnostics', async () => { + await trackIncomingRequestStats(mockCtx, mockNext) + + mockRequest.emit('aborted') + + expect(incomingRequestStats.get().aborted).toBe(1) + expect(mockDiagnosticsMetrics.incrementCounter).toHaveBeenCalledWith( + 'http_server_requests_aborted_total', + 1, + { + route_id: 'test-route', + route_type: 'public', + status_code: 200, + } + ) + }) + + it('should cancel request when aborted and cancellation is available', async () => { + await trackIncomingRequestStats(mockCtx, mockNext) + + mockRequest.emit('aborted') + + expect(mockCtx.vtex.cancellation.source.cancel).toHaveBeenCalledWith('Request cancelled') + expect(mockCtx.vtex.cancellation.cancelled).toBe(true) + }) + + it('should handle multiple events correctly', async () => { + await trackIncomingRequestStats(mockCtx, mockNext) + + mockRequest.emit('close') + mockRequest.emit('aborted') + + expect(incomingRequestStats.get().total).toBe(1) + expect(incomingRequestStats.get().closed).toBe(1) + expect(incomingRequestStats.get().aborted).toBe(1) + }) + + it('should work without global.diagnosticsMetrics', async () => { + delete (global as any).diagnosticsMetrics + + // Should not throw + await expect(trackIncomingRequestStats(mockCtx, mockNext)).resolves.not.toThrow() + + expect(incomingRequestStats.get().total).toBe(1) + }) + + it('should handle request events without diagnostics metrics', async () => { + delete (global as any).diagnosticsMetrics + + await trackIncomingRequestStats(mockCtx, mockNext) + + // Should not throw when events are emitted + expect(() => mockRequest.emit('close')).not.toThrow() + expect(() => mockRequest.emit('aborted')).not.toThrow() + + expect(incomingRequestStats.get().closed).toBe(1) + expect(incomingRequestStats.get().aborted).toBe(1) + }) + }) +}) + + + + diff --git a/src/service/worker/runtime/http/middlewares/requestStats.ts b/src/service/worker/runtime/http/middlewares/requestStats.ts index 6510968c8..7b6bda179 100644 --- a/src/service/worker/runtime/http/middlewares/requestStats.ts +++ b/src/service/worker/runtime/http/middlewares/requestStats.ts @@ -1,4 +1,5 @@ import { IOClients } from '../../../../../clients/IOClients' +import { AttributeKeys } from '../../../../../constants' import { ParamsContext, RecorderState, ServiceContext } from '../../typings' export const cancelMessage = 'Request cancelled' @@ -25,8 +26,26 @@ class IncomingRequestStats { export const incomingRequestStats = new IncomingRequestStats() -const requestClosed = () => { +const requestClosed = < + T extends IOClients, + U extends RecorderState, + V extends ParamsContext +>(ctx: ServiceContext) => () => { incomingRequestStats.closed++ + + // Report to diagnostics metrics (cumulative counter) + const { status: statusCode, vtex: { account, route: { id, type } } } = ctx + + if (global.diagnosticsMetrics) { + global.diagnosticsMetrics.incrementCounter('http_server_requests_closed_total', 1, { + [AttributeKeys.VTEX_ACCOUNT_NAME]: account, + route_id: id, + route_type: type, + status_code: statusCode, + }) + } else { + console.warn('DiagnosticsMetrics not available. Request closed metric not reported.') + } } const requestAborted = < T extends IOClients, @@ -35,6 +54,20 @@ const requestAborted = < >(ctx: ServiceContext) => () => { incomingRequestStats.aborted++ + // Report to diagnostics metrics (cumulative counter) + const { status: statusCode, vtex: { account, route: { id, type } } } = ctx + + if (global.diagnosticsMetrics) { + global.diagnosticsMetrics.incrementCounter('http_server_requests_aborted_total', 1, { + [AttributeKeys.VTEX_ACCOUNT_NAME]: account, + route_id: id, + route_type: type, + status_code: statusCode, + }) + } else { + console.warn('DiagnosticsMetrics not available. Request aborted metric not reported.') + } + if (ctx.vtex.cancellation && ctx.vtex.cancellation.cancelable) { ctx.vtex.cancellation.source.cancel(cancelMessage) ctx.vtex.cancellation.cancelled = true @@ -46,8 +79,22 @@ export async function trackIncomingRequestStats < U extends RecorderState, V extends ParamsContext > (ctx: ServiceContext, next: () => Promise) { - ctx.req.on('close', requestClosed) + ctx.req.on('close', requestClosed(ctx)) ctx.req.on('aborted', requestAborted(ctx)) incomingRequestStats.total++ + + // Report total requests to diagnostics metrics (cumulative counter) + const { status: statusCode, vtex: { account, route: { id, type } } } = ctx + if (global.diagnosticsMetrics) { + global.diagnosticsMetrics.incrementCounter('http_server_requests_total', 1, { + [AttributeKeys.VTEX_ACCOUNT_NAME]: account, + route_id: id, + route_type: type, + status_code: statusCode, + }) + } else { + console.warn('DiagnosticsMetrics not available. Request total metric not reported.') + } + await next() } diff --git a/src/service/worker/runtime/http/middlewares/timings.test.ts b/src/service/worker/runtime/http/middlewares/timings.test.ts new file mode 100644 index 000000000..f2bc3b3d6 --- /dev/null +++ b/src/service/worker/runtime/http/middlewares/timings.test.ts @@ -0,0 +1,262 @@ +// Mock @vtex/diagnostics-nodejs before any imports +jest.mock('@vtex/diagnostics-nodejs', () => ({ + Types: {}, + getLogger: jest.fn(), + getMetricClient: jest.fn(), +})) + +jest.mock('../../../../../service/metrics/client', () => ({ + getMetricClient: jest.fn(), +})) + +import { DiagnosticsMetrics } from '../../../../../metrics/DiagnosticsMetrics' +import { timings } from './timings' + +describe('timings middleware', () => { + let mockDiagnosticsMetrics: jest.Mocked + let mockNext: jest.Mock + let mockCtx: any + let consoleLogSpy: jest.SpyInstance + + beforeEach(() => { + // Mock DiagnosticsMetrics with runWithBaseAttributes that executes the function + mockDiagnosticsMetrics = { + incrementCounter: jest.fn(), + recordLatency: jest.fn(), + runWithBaseAttributes: jest.fn((baseAttributes, fn) => fn()), + setGauge: jest.fn(), + } as any + + // Set up global + global.diagnosticsMetrics = mockDiagnosticsMetrics + + // Mock global.metrics for legacy support + ;(global as any).metrics = { + batch: jest.fn(), + } + + // Mock next function + mockNext = jest.fn().mockResolvedValue(undefined) + + // Mock console.log to avoid test output noise + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation() + + // Mock context + mockCtx = { + method: 'GET', + path: '/test', + status: 200, + timings: { + total: [1, 500000000], // 1.5 seconds + }, + vtex: { + account: 'testaccount', + production: true, + route: { + id: 'test-route', + type: 'public', + }, + workspace: 'master', + }, + } + }) + + afterEach(() => { + jest.clearAllMocks() + consoleLogSpy.mockRestore() + delete (global as any).diagnosticsMetrics + delete (global as any).metrics + }) + + describe('successful requests', () => { + it('should record metrics for successful request', async () => { + await timings(mockCtx, mockNext) + + // Verify runWithBaseAttributes is called with base attributes + expect(mockDiagnosticsMetrics.runWithBaseAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + component: 'http-handler', + route_id: 'test-route', + route_type: 'public', + 'vtex.account.name': 'testaccount', + }), + expect.any(Function) + ) + + // Diagnostics metrics - now only receive completion-specific attributes + // Base attributes (account, route_id, etc.) are merged internally by DiagnosticsMetrics + expect(mockDiagnosticsMetrics.recordLatency).toHaveBeenCalledWith( + [1, 500000000], + expect.objectContaining({ + status: 'success', + status_code: 200, + }) + ) + + expect(mockDiagnosticsMetrics.incrementCounter).toHaveBeenCalledWith( + 'http_handler_requests_total', + 1, + expect.objectContaining({ + status: 'success', + status_code: 200, + }) + ) + }) + + it('should log timing and billing information', async () => { + await timings(mockCtx, mockNext) + + // Check console.log was called twice (timing log + billing log) + expect(consoleLogSpy).toHaveBeenCalledTimes(2) + + // Verify billing info structure + const billingCall = consoleLogSpy.mock.calls[1][0] + const billingInfo = JSON.parse(billingCall) + + expect(billingInfo).toMatchObject({ + __VTEX_IO_BILLING: 'true', + account: 'testaccount', + handler: 'test-route', + production: true, + routeType: 'public_route', + type: 'process-time', + workspace: 'master', + }) + expect(billingInfo.value).toBeGreaterThan(0) // millis + }) + + it('should maintain legacy metrics compatibility', async () => { + await timings(mockCtx, mockNext) + + // Verify legacy metrics.batch was called + expect((global as any).metrics.batch).toHaveBeenCalledWith( + 'http-handler-test-route', + [1, 500000000], + { success: 1 } + ) + }) + }) + + describe('error responses', () => { + it('should record metrics for 4xx errors', async () => { + mockCtx.status = 404 + + await timings(mockCtx, mockNext) + + const latencyCall = mockDiagnosticsMetrics.recordLatency.mock.calls[0] + expect(latencyCall[1]).toMatchObject({ + status: '4xx', + status_code: 404, + }) + + // Counter with status as attribute (base attributes merged internally) + expect(mockDiagnosticsMetrics.incrementCounter).toHaveBeenCalledWith( + 'http_handler_requests_total', + 1, + expect.objectContaining({ + status: '4xx', + status_code: 404, + }) + ) + }) + + it('should record metrics for 5xx errors', async () => { + mockCtx.status = 500 + + await timings(mockCtx, mockNext) + + expect(mockDiagnosticsMetrics.recordLatency).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + status: 'error', + status_code: 500, + }) + ) + + // Verify legacy only batches successful responses (no hrtime for errors) + expect((global as any).metrics.batch).toHaveBeenCalledWith( + 'http-handler-test-route', + undefined, + { error: 1 } + ) + }) + }) + + describe('route types', () => { + it('should correctly identify private routes in billing info', async () => { + mockCtx.vtex.route.type = 'private' + + await timings(mockCtx, mockNext) + + const billingCall = consoleLogSpy.mock.calls[1][0] + const billingInfo = JSON.parse(billingCall) + + expect(billingInfo.routeType).toBe('private_route') + }) + }) + + describe('graceful degradation', () => { + it('should work without global.diagnosticsMetrics', async () => { + delete (global as any).diagnosticsMetrics + + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation() + + await timings(mockCtx, mockNext) + + // Legacy metrics still work + expect((global as any).metrics.batch).toHaveBeenCalled() + + // Warning logged + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'DiagnosticsMetrics not available. HTTP handler metrics not reported.' + ) + + consoleWarnSpy.mockRestore() + }) + }) + + describe('different status codes', () => { + const testCases = [ + { status: 200, expected: 'success' }, + { status: 201, expected: 'success' }, + { status: 204, expected: 'success' }, + { status: 301, expected: '3xx' }, // statusLabel returns range-based labels + { status: 302, expected: '3xx' }, + { status: 400, expected: '4xx' }, + { status: 401, expected: '4xx' }, + { status: 403, expected: '4xx' }, + { status: 404, expected: '4xx' }, + { status: 500, expected: 'error' }, + { status: 502, expected: 'error' }, + { status: 503, expected: 'error' }, + ] + + testCases.forEach(({ status, expected }) => { + it(`should categorize status ${status} as ${expected}`, async () => { + mockCtx.status = status + + await timings(mockCtx, mockNext) + + const latencyCall = mockDiagnosticsMetrics.recordLatency.mock.calls[0] + expect(latencyCall[1]).toMatchObject({ + status: expected, + }) + }) + }) + }) + + describe('middleware execution', () => { + it('should call next() before recording metrics', async () => { + let nextCalled = false + mockNext.mockImplementation(async () => { + nextCalled = true + }) + + await timings(mockCtx, mockNext) + + expect(nextCalled).toBe(true) + expect(mockNext).toHaveBeenCalledTimes(1) + }) + }) +}) + diff --git a/src/service/worker/runtime/http/middlewares/timings.ts b/src/service/worker/runtime/http/middlewares/timings.ts index 6d000053d..600deedd2 100644 --- a/src/service/worker/runtime/http/middlewares/timings.ts +++ b/src/service/worker/runtime/http/middlewares/timings.ts @@ -1,7 +1,8 @@ +import { Attributes } from '@opentelemetry/api' import chalk from 'chalk' import { IOClients } from '../../../../../clients/IOClients' -import { APP, LINKED, PID } from '../../../../../constants' +import { APP, AttributeKeys, LINKED, PID } from '../../../../../constants' import { statusLabel } from '../../../../../utils/status' import { formatTimingName, @@ -60,15 +61,60 @@ export async function timings < U extends RecorderState, V extends ParamsContext > (ctx: ServiceContext, next: () => Promise) { - // Errors will be caught by the next middleware so we don't have to catch. - await next() + const { vtex: { route: { id, type } }, vtex } = ctx - const { status: statusCode, vtex: { route: { id } }, timings: {total}, vtex } = ctx - const totalMillis = hrToMillis(total) - console.log(log(ctx, totalMillis)) - console.log(logBillingInfo(vtex, totalMillis)) + // Set base attributes for all metrics recorded during this request. + // This includes metrics recorded by VTEX IO apps during handler execution. + // These attributes will be automatically merged with custom attributes. + const baseAttributes: Attributes = { + [AttributeKeys.VTEX_ACCOUNT_NAME]: vtex.account, + component: 'http-handler', + route_id: id, + route_type: type, + } - const status = statusLabel(statusCode) - // Only batch successful responses so metrics don't consider errors - metrics.batch(`http-handler-${id}`, status === 'success' ? total : undefined, { [status]: 1 }) + // Wrap the request handling with base attributes context. + // All metrics recorded during next() will automatically include these attributes. + const executeWithBaseAttributes = async () => { + // Errors will be caught by the next middleware so we don't have to catch. + await next() + + const { status: statusCode, timings: {total} } = ctx + const totalMillis = hrToMillis(total) + console.log(log(ctx, totalMillis)) + console.log(logBillingInfo(vtex, totalMillis)) + + const status = statusLabel(statusCode) + + // Legacy metrics (backward compatibility) + // Only batch successful responses so metrics don't consider errors + metrics.batch(`http-handler-${id}`, status === 'success' ? total : undefined, { [status]: 1 }) + + // New diagnostics metrics with stable names and attributes + // Note: base attributes (account, route_id, route_type) are automatically merged + // We only need to provide the response-specific attributes here + if (global.diagnosticsMetrics) { + const responseAttributes: Attributes = { + status, + status_code: statusCode, + } + + // Record latency histogram (record all requests, not just successful ones) + global.diagnosticsMetrics.recordLatency(total, responseAttributes) + + // Increment counter (status is an attribute, not in metric name) + global.diagnosticsMetrics.incrementCounter('http_handler_requests_total', 1, responseAttributes) + } else { + console.warn('DiagnosticsMetrics not available. HTTP handler metrics not reported.') + } + } + + // If diagnosticsMetrics is available, run with base attributes context + // Otherwise, run without context (fallback for graceful degradation) + if (global.diagnosticsMetrics) { + await global.diagnosticsMetrics.runWithBaseAttributes(baseAttributes, executeWithBaseAttributes) + } else { + console.warn('DiagnosticsMetrics not available. HTTP handler metrics not reported.') + await executeWithBaseAttributes() + } } diff --git a/src/service/worker/runtime/statusTrack.ts b/src/service/worker/runtime/statusTrack.ts index 9356cf6b4..699fd5ee5 100644 --- a/src/service/worker/runtime/statusTrack.ts +++ b/src/service/worker/runtime/statusTrack.ts @@ -1,5 +1,6 @@ import cluster from 'cluster' +import { HttpAgentSingleton } from '../../../HttpClient/middlewares/request/HttpAgentSingleton' import { LINKED } from '../../../constants' import { ServiceContext } from './typings' @@ -36,6 +37,9 @@ export const statusTrackHandler = async (ctx: ServiceContext) => { } export const trackStatus = () => { + // Update diagnostics metrics (gauges for HTTP agent stats) + HttpAgentSingleton.updateHttpAgentMetrics() + // Flushing resets the metric accumulators, the CPU usage baseline and the // incoming request stats, so it must keep running even though nothing // consumes the returned metrics anymore. From 82ca21b755cdcd385b515282df586816e917f5b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlio=20Guedes?= Date: Thu, 17 Sep 2026 14:45:10 -0300 Subject: [PATCH 5/7] chore(release): bump version to 6.53.0-beta.1 Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16f26706f..7d3696bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## [6.53.0-beta.0] +## [6.53.0-beta.1] ### Added - Diagnostics metrics observability, backported from the `master` (7.x) line: `DiagnosticsMetrics` (`recordLatency`, `incrementCounter`, `setGauge`, `runWithBaseAttributes`), a split diff --git a/package.json b/package.json index 4c8293230..ebcfd1510 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vtex/api", - "version": "6.53.0-beta.0", + "version": "6.53.0-beta.1", "description": "VTEX I/O API client", "main": "lib/index.js", "typings": "lib/index.d.ts", From 83aeae63b35a447a9454e7474002a4cb625c83f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlio=20Guedes?= Date: Thu, 17 Sep 2026 17:21:32 -0300 Subject: [PATCH 6/7] debug(telemetry): hardcode debug:true to diagnose 6.x export silence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deployed 6.53.0-beta.1 to iotest-ju2 and confirmed via ClickHouse that 6.x apps (and even the pre-existing structured logger, predating this backport) have never delivered a single row of telemetry, in any cluster, ever — despite clean "Telemetry enabled" init logs, correct env vars, open network path, and 7.x apps in the exact same cluster succeeding continuously with the same new metric names. @vtex/diagnostics-nodejs's TelemetryClient supports a debug option that enables the OTel SDK's own diagnostic console logging at DEBUG level, surfacing real export-attempt errors instead of them failing silently. Hardcoded on (not env-gated) since this is a throwaway beta build meant purely to capture that error in pod logs; revert once root-caused. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 +++++- package.json | 2 +- src/service/telemetry/client.ts | 6 ++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d3696bf0..64e3c70c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## [6.53.0-beta.1] +## [6.53.0-beta.2] ### Added - Diagnostics metrics observability, backported from the `master` (7.x) line: `DiagnosticsMetrics` (`recordLatency`, `incrementCounter`, `setGauge`, `runWithBaseAttributes`), a split @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. HTTP client metrics, HTTP agent socket gauges, and the `@metric` GraphQL directive all emit through `DiagnosticsMetrics` at the same points `master` does. Disabled by default; opt in per app with `VTEX_DIAGNOSTICS_TELEMETRY_ENABLED=true`. +### Debug +- TEMPORARY: `debug: true` hardcoded in the `NewTelemetryClient` call, to diagnose why no + telemetry from `6.x` apps reaches ClickHouse despite clean initialization logs. Surfaces the + underlying OTel SDK's real export-attempt errors via the console. Revert once root-caused. ## [6.52.0] ### Added diff --git a/package.json b/package.json index ebcfd1510..cccf0e257 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vtex/api", - "version": "6.53.0-beta.1", + "version": "6.53.0-beta.2", "description": "VTEX I/O API client", "main": "lib/index.js", "typings": "lib/index.d.ts", diff --git a/src/service/telemetry/client.ts b/src/service/telemetry/client.ts index 9a3de4239..7270220fc 100644 --- a/src/service/telemetry/client.ts +++ b/src/service/telemetry/client.ts @@ -116,6 +116,12 @@ class TelemetryClientSingleton { }, // Use built-in no-op functionality when telemetry is disabled noop: !DIAGNOSTICS_TELEMETRY_ENABLED, + // TEMPORARY: hardcoded on to diagnose why no telemetry from 6.x apps reaches + // ClickHouse in iotest-ju2 despite clean init logs. Turns on the underlying + // @opentelemetry/api diag console logger at DEBUG level, surfacing real + // export-attempt errors instead of them failing silently in the background. + // Revert once the root cause is found. + debug: true, } ) From 078bc5e6a90ed2f3d80bd2721e4d0e8690da9b02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlio=20Guedes?= Date: Thu, 17 Sep 2026 18:04:34 -0300 Subject: [PATCH 7/7] fix(telemetry): pin @grpc/grpc-js to fix dual-package hazard blocking exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of no 6.x telemetry ever reaching ClickHouse, found via the debug:true diagnostic build deployed to iotest-ju2. Pod logs showed: TypeError: Channel credentials must be a ChannelCredentials object yarn install had resolved @grpc/grpc-js into two separate copies: 1.14.4 (from @vtex/diagnostics-nodejs's own ^1.13.4 requirement) and 1.13.3 (from the four @opentelemetry/exporter-*-otlp-grpc packages' ^1.7.1 requirement). The ChannelCredentials object built by one copy failed an instanceof check performed by the other, so every metrics/ traces/logs export failed silently in the background — initialization itself never errored, hence the clean "Telemetry enabled" logs despite zero data ever arriving. master never hit this: its yarn.lock happened to collapse both ranges onto a single 1.13.4 resolution naturally. Added a `resolutions` field pinning @grpc/grpc-js to 1.13.4 (matching master's resolution exactly) so 6.x gets the same single, shared copy. Also reverts the temporary debug:true flag added purely to capture this error, now that root cause is found. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 18 ++++++--- package.json | 5 ++- src/service/telemetry/client.ts | 6 --- yarn.lock | 67 +++------------------------------ 4 files changed, 22 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64e3c70c2..b71f6e17f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## [6.53.0-beta.2] +## [6.53.0-beta.3] ### Added - Diagnostics metrics observability, backported from the `master` (7.x) line: `DiagnosticsMetrics` (`recordLatency`, `incrementCounter`, `setGauge`, `runWithBaseAttributes`), a split @@ -16,10 +16,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. HTTP client metrics, HTTP agent socket gauges, and the `@metric` GraphQL directive all emit through `DiagnosticsMetrics` at the same points `master` does. Disabled by default; opt in per app with `VTEX_DIAGNOSTICS_TELEMETRY_ENABLED=true`. -### Debug -- TEMPORARY: `debug: true` hardcoded in the `NewTelemetryClient` call, to diagnose why no - telemetry from `6.x` apps reaches ClickHouse despite clean initialization logs. Surfaces the - underlying OTel SDK's real export-attempt errors via the console. Revert once root-caused. +### Fixed +- No metrics/traces/logs from `6.x` apps ever reached ClickHouse, despite clean + "Telemetry enabled" initialization logs. Root cause (found via a temporary `debug: true` + diagnostic build): `yarn install` resolved `@grpc/grpc-js` into two separate copies — + `1.14.4` (satisfying `@vtex/diagnostics-nodejs`'s own `^1.13.4` requirement) and `1.13.3` + (satisfying the four `@opentelemetry/exporter-*-otlp-grpc` packages' `^1.7.1` requirement) — + a classic dual-package hazard: the `ChannelCredentials` object built by one copy failed an + `instanceof` check performed by the other (`TypeError: Channel credentials must be a + ChannelCredentials object`), so every export silently failed in the background. `master` + never hit this because its `yarn.lock` happened to collapse both ranges onto a single + `1.13.4` resolution. Fixed with a `resolutions` pin forcing `@grpc/grpc-js` to `1.13.4` + everywhere, matching `master`'s naturally-deduped resolution. ## [6.52.0] ### Added diff --git a/package.json b/package.json index cccf0e257..3d8e37148 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vtex/api", - "version": "6.53.0-beta.2", + "version": "6.53.0-beta.3", "description": "VTEX I/O API client", "main": "lib/index.js", "typings": "lib/index.d.ts", @@ -129,5 +129,8 @@ "typemoq": "^2.1.0", "typescript": "^4.4.4", "typescript-json-schema": "^0.52.0" + }, + "resolutions": { + "@grpc/grpc-js": "1.13.4" } } diff --git a/src/service/telemetry/client.ts b/src/service/telemetry/client.ts index 7270220fc..9a3de4239 100644 --- a/src/service/telemetry/client.ts +++ b/src/service/telemetry/client.ts @@ -116,12 +116,6 @@ class TelemetryClientSingleton { }, // Use built-in no-op functionality when telemetry is disabled noop: !DIAGNOSTICS_TELEMETRY_ENABLED, - // TEMPORARY: hardcoded on to diagnose why no telemetry from 6.x apps reaches - // ClickHouse in iotest-ju2 despite clean init logs. Turns on the underlying - // @opentelemetry/api diag console logger at DEBUG level, surfacing real - // export-attempt errors instead of them failing silently in the background. - // Revert once the root cause is found. - debug: true, } ) diff --git a/yarn.lock b/yarn.lock index 1a41b95b6..1e49d8c4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -236,18 +236,10 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" -"@grpc/grpc-js@^1.13.4": - version "1.14.4" - resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.14.4.tgz#e73ff57d97802f063999545f43ebb2b1eca65d9d" - integrity sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ== - dependencies: - "@grpc/proto-loader" "^0.8.0" - "@js-sdsl/ordered-map" "^4.4.2" - -"@grpc/grpc-js@^1.7.1": - version "1.13.3" - resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.13.3.tgz#6ad08d186c2a8651697085f790c5c68eaca45904" - integrity sha512-FTXHdOoPbZrBjlVLHuKbDZnsTxXv2BlHF57xw6LuThXacXvtkahEPED0CKMk6obZDf65Hv4k3z62eyPNpvinIg== +"@grpc/grpc-js@1.13.4", "@grpc/grpc-js@^1.13.4", "@grpc/grpc-js@^1.7.1": + version "1.13.4" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.13.4.tgz#922fbc496e229c5fa66802d2369bf181c1df1c5a" + integrity sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg== dependencies: "@grpc/proto-loader" "^0.7.13" "@js-sdsl/ordered-map" "^4.4.2" @@ -262,16 +254,6 @@ protobufjs "^7.2.5" yargs "^17.7.2" -"@grpc/proto-loader@^0.8.0": - version "0.8.1" - resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.8.1.tgz#5a6b290ccbfb1ae2f6775afb74e9898bd8c5d4e8" - integrity sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg== - dependencies: - lodash.camelcase "^4.3.0" - long "^5.0.0" - protobufjs "^7.5.5" - yargs "^17.7.2" - "@istanbuljs/load-nyc-config@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.0.0.tgz#10602de5570baea82f8afbfa2630b24e7a8cfe5b" @@ -841,21 +823,11 @@ resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb" integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg== -"@protobufjs/codegen@^2.0.5": - version "2.0.5" - resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.5.tgz#d9315ad7cf3f30aac70bda3c068443dc6f143659" - integrity sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g== - "@protobufjs/eventemitter@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70" integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== -"@protobufjs/eventemitter@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz#d512cb26c0ae026091ee2c1167f1be6faf5c842a" - integrity sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg== - "@protobufjs/fetch@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45" @@ -864,13 +836,6 @@ "@protobufjs/aspromise" "^1.1.1" "@protobufjs/inquire" "^1.1.0" -"@protobufjs/fetch@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.1.tgz#4d6fc00c8fb64016a5c81b469d549046350f1065" - integrity sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw== - dependencies: - "@protobufjs/aspromise" "^1.1.1" - "@protobufjs/float@^1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" @@ -896,11 +861,6 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== -"@protobufjs/utf8@^1.1.1": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.2.tgz#78d476333d85d5b1c792e257bca74ba080da49a4" - integrity sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug== - "@sinonjs/commons@^1.7.0": version "1.7.1" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.7.1.tgz#da5fd19a5f71177a53778073978873964f49acf1" @@ -4012,7 +3972,7 @@ long@^2.4.0: resolved "https://registry.yarnpkg.com/long/-/long-2.4.0.tgz#9fa180bb1d9500cdc29c4156766a1995e1f4524f" integrity sha1-n6GAux2VAM3CnEFWdmoZleH0Uk8= -long@^5.0.0, long@^5.3.2: +long@^5.0.0: version "5.3.2" resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== @@ -4611,23 +4571,6 @@ protobufjs@^7.2.5, protobufjs@^7.3.0: "@types/node" ">=13.7.0" long "^5.0.0" -protobufjs@^7.5.5: - version "7.6.6" - resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.6.6.tgz#7a3923e8e32b0ee2ff689f8eed6e455c090ac67e" - integrity sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg== - dependencies: - "@protobufjs/aspromise" "^1.1.2" - "@protobufjs/base64" "^1.1.2" - "@protobufjs/codegen" "^2.0.5" - "@protobufjs/eventemitter" "^1.1.1" - "@protobufjs/fetch" "^1.1.1" - "@protobufjs/float" "^1.0.2" - "@protobufjs/path" "^1.1.2" - "@protobufjs/pool" "^1.1.0" - "@protobufjs/utf8" "^1.1.1" - "@types/node" ">=13.7.0" - long "^5.3.2" - proxy-from-env@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"