@vtex/api
Version:
VTEX I/O API client
343 lines (342 loc) • 15 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.DiagnosticsMetrics = void 0;
const api_1 = require("@opentelemetry/api");
const client_1 = require("../service/metrics/client");
const constants_1 = require("../constants");
/**
* 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 = (0, api_1.createContextKey)('vtex.metrics.baseAttributes');
/**
* Converts an hrtime tuple [seconds, nanoseconds] to milliseconds.
*/
function hrtimeToMillis(hrtime) {
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) {
if (!customAttributes) {
return undefined;
}
const entries = Object.entries(customAttributes);
if (entries.length <= MAX_CUSTOM_ATTRIBUTES) {
return customAttributes;
}
if (constants_1.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' }
* }
* )
* ```
*/
class DiagnosticsMetrics {
constructor() {
this.counters = new Map();
this.gauges = new Map();
this.initMetricClient();
}
/**
* Initialize the metrics client with timeout handling.
* Called automatically in constructor.
*/
initMetricClient() {
if (this.clientInitPromise) {
return this.clientInitPromise;
}
this.clientInitPromise = (async () => {
try {
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Metric client initialization timeout')), constants_1.METRIC_CLIENT_INIT_TIMEOUT_MS);
});
this.metricsClient = await Promise.race([
(0, client_1.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.
*/
createLatencyHistogram() {
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
* ```
*/
runWithBaseAttributes(baseAttributes, fn) {
const currentContext = api_1.context.active();
const newContext = currentContext.setValue(BASE_ATTRIBUTES_KEY, baseAttributes);
return api_1.context.with(newContext, fn);
}
/**
* Get the base attributes from the current OpenTelemetry context.
* Returns undefined if no base attributes are set.
*/
getBaseAttributes() {
return api_1.context.active().getValue(BASE_ATTRIBUTES_KEY);
}
/**
* 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
*/
mergeAttributes(customAttributes) {
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 = {};
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' })
* ```
*/
recordLatency(value, attributes) {
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' })
* ```
*/
incrementCounter(name, value, attributes) {
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' })
* ```
*/
setGauge(name, value, attributes) {
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);
}
}
exports.DiagnosticsMetrics = DiagnosticsMetrics;