refactor progress
This commit is contained in:
123
packages/telemetry/src/Metrics.tsx
Normal file
123
packages/telemetry/src/Metrics.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {type Attributes, type Counter, type Histogram, type Meter, metrics} from '@opentelemetry/api';
|
||||
|
||||
const METER_NAME = 'fluxer.telemetry';
|
||||
|
||||
export interface CounterMetric {
|
||||
name: string;
|
||||
value?: number;
|
||||
dimensions?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface HistogramMetric {
|
||||
name: string;
|
||||
valueMs: number;
|
||||
dimensions?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface GaugeMetric {
|
||||
name: string;
|
||||
value: number;
|
||||
dimensions?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface IMetricRegistry {
|
||||
getCounter(name: string, description?: string): Counter<Attributes>;
|
||||
getHistogram(name: string, description?: string): Histogram<Attributes>;
|
||||
}
|
||||
|
||||
class TelemetryMetricRegistry implements IMetricRegistry {
|
||||
private readonly counters = new Map<string, Counter<Attributes>>();
|
||||
private readonly histograms = new Map<string, Histogram<Attributes>>();
|
||||
private readonly meter: Meter;
|
||||
|
||||
public constructor() {
|
||||
this.meter = metrics.getMeter(METER_NAME);
|
||||
}
|
||||
|
||||
public getCounter(name: string, description?: string): Counter<Attributes> {
|
||||
const existingCounter = this.counters.get(name);
|
||||
if (existingCounter !== undefined) {
|
||||
return existingCounter;
|
||||
}
|
||||
const counter = this.meter.createCounter(name, {
|
||||
description: description ?? `Counter for ${name}`,
|
||||
});
|
||||
this.counters.set(name, counter);
|
||||
return counter;
|
||||
}
|
||||
|
||||
public getHistogram(name: string, description?: string): Histogram<Attributes> {
|
||||
const existingHistogram = this.histograms.get(name);
|
||||
if (existingHistogram !== undefined) {
|
||||
return existingHistogram;
|
||||
}
|
||||
const histogram = this.meter.createHistogram(name, {
|
||||
description: description ?? `Histogram for ${name}`,
|
||||
});
|
||||
this.histograms.set(name, histogram);
|
||||
return histogram;
|
||||
}
|
||||
}
|
||||
|
||||
const metricRegistry = new TelemetryMetricRegistry();
|
||||
|
||||
function toAttributes(dimensions?: Record<string, string>): Attributes {
|
||||
if (dimensions === undefined) {
|
||||
return {};
|
||||
}
|
||||
return {...dimensions};
|
||||
}
|
||||
|
||||
export function createCounter(name: string, description?: string): Counter<Attributes> {
|
||||
return metricRegistry.getCounter(name, description);
|
||||
}
|
||||
|
||||
export function createHistogram(name: string, description?: string): Histogram<Attributes> {
|
||||
return metricRegistry.getHistogram(name, description);
|
||||
}
|
||||
|
||||
export function recordCounter(name: string, value?: number, attributes?: Attributes): void;
|
||||
export function recordCounter(metric: CounterMetric): void;
|
||||
export function recordCounter(nameOrMetric: string | CounterMetric, value: number = 1, attributes?: Attributes): void {
|
||||
if (typeof nameOrMetric === 'string') {
|
||||
createCounter(nameOrMetric).add(value, attributes);
|
||||
return;
|
||||
}
|
||||
createCounter(nameOrMetric.name).add(nameOrMetric.value ?? 1, toAttributes(nameOrMetric.dimensions));
|
||||
}
|
||||
|
||||
export function recordHistogram(name: string, value: number, attributes?: Attributes): void;
|
||||
export function recordHistogram(metric: HistogramMetric): void;
|
||||
export function recordHistogram(nameOrMetric: string | HistogramMetric, value?: number, attributes?: Attributes): void {
|
||||
if (typeof nameOrMetric === 'string') {
|
||||
if (value === undefined) {
|
||||
throw new Error(`Histogram metric "${nameOrMetric}" requires a numeric value`);
|
||||
}
|
||||
createHistogram(nameOrMetric).record(value, attributes);
|
||||
return;
|
||||
}
|
||||
createHistogram(nameOrMetric.name).record(nameOrMetric.valueMs, toAttributes(nameOrMetric.dimensions));
|
||||
}
|
||||
|
||||
export function recordGauge(metric: GaugeMetric): void {
|
||||
createHistogram(metric.name).record(metric.value, toAttributes(metric.dimensions));
|
||||
}
|
||||
142
packages/telemetry/src/Telemetry.tsx
Normal file
142
packages/telemetry/src/Telemetry.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {getDefaultTelemetryConfig} from '@fluxer/telemetry/src/telemetry_runtime/TelemetryConfig';
|
||||
import {
|
||||
createDefaultTelemetryLogger,
|
||||
type ITelemetryInitLogger,
|
||||
} from '@fluxer/telemetry/src/telemetry_runtime/TelemetryLogger';
|
||||
import {
|
||||
createTelemetryRuntimeManager,
|
||||
type ITelemetryRuntimeManager,
|
||||
} from '@fluxer/telemetry/src/telemetry_runtime/TelemetryManager';
|
||||
|
||||
export interface InstrumentationConfig {
|
||||
cassandra?: boolean;
|
||||
aws?: boolean;
|
||||
fetch?: boolean;
|
||||
}
|
||||
|
||||
export interface TelemetryConfig {
|
||||
enabled: boolean;
|
||||
serviceName: string;
|
||||
serviceVersion: string;
|
||||
serviceInstanceId: string;
|
||||
environment: string;
|
||||
traceSamplingRatio: number;
|
||||
otlpEndpoint: string;
|
||||
otlpApiKey?: string;
|
||||
exportTimeout: number;
|
||||
metricExportIntervalMs: number;
|
||||
ignoreIncomingPaths: Array<string>;
|
||||
instrumentations?: InstrumentationConfig;
|
||||
}
|
||||
|
||||
export interface OtlpConfig {
|
||||
endpoints: {
|
||||
traces: string;
|
||||
metrics: string;
|
||||
logs: string;
|
||||
};
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TelemetryInitLogger {
|
||||
info(msg: string): void;
|
||||
info(obj: Record<string, unknown>, msg: string): void;
|
||||
}
|
||||
|
||||
export interface ITelemetryManager {
|
||||
initialize(config?: Partial<TelemetryConfig>): Promise<void>;
|
||||
shutdown(): Promise<void>;
|
||||
isActive(): boolean;
|
||||
shouldInitialize(): boolean;
|
||||
getConfig(): TelemetryConfig | null;
|
||||
getOtlpConfig(): OtlpConfig | null;
|
||||
}
|
||||
|
||||
export interface TelemetryManagerOptions {
|
||||
logger?: TelemetryInitLogger;
|
||||
shouldInitialize?: () => boolean;
|
||||
}
|
||||
|
||||
function createRuntimeManager(options?: TelemetryManagerOptions): ITelemetryRuntimeManager {
|
||||
const logger: ITelemetryInitLogger = options?.logger ?? createDefaultTelemetryLogger();
|
||||
return createTelemetryRuntimeManager({
|
||||
logger,
|
||||
shouldInitialize: options?.shouldInitialize ?? (() => true),
|
||||
});
|
||||
}
|
||||
|
||||
function createManagerFacade(runtimeManager: ITelemetryRuntimeManager): ITelemetryManager {
|
||||
return {
|
||||
initialize(config?: Partial<TelemetryConfig>): Promise<void> {
|
||||
return runtimeManager.initialize(config);
|
||||
},
|
||||
shutdown(): Promise<void> {
|
||||
return runtimeManager.shutdown();
|
||||
},
|
||||
isActive(): boolean {
|
||||
return runtimeManager.isActive();
|
||||
},
|
||||
shouldInitialize(): boolean {
|
||||
return runtimeManager.shouldInitialize();
|
||||
},
|
||||
getConfig(): TelemetryConfig | null {
|
||||
return runtimeManager.getConfig();
|
||||
},
|
||||
getOtlpConfig(): OtlpConfig | null {
|
||||
return runtimeManager.getOtlpConfig();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createTelemetryManager(options?: TelemetryManagerOptions): ITelemetryManager {
|
||||
return createManagerFacade(createRuntimeManager(options));
|
||||
}
|
||||
|
||||
const defaultTelemetryManager = createTelemetryManager();
|
||||
|
||||
export function isTelemetryActive(): boolean {
|
||||
return defaultTelemetryManager.isActive();
|
||||
}
|
||||
|
||||
export function getDefaultConfig(): TelemetryConfig {
|
||||
return getDefaultTelemetryConfig();
|
||||
}
|
||||
|
||||
export function shouldInitializeTelemetry(): boolean {
|
||||
return defaultTelemetryManager.shouldInitialize();
|
||||
}
|
||||
|
||||
export async function initializeTelemetry(config?: Partial<TelemetryConfig>): Promise<void> {
|
||||
await defaultTelemetryManager.initialize(config);
|
||||
}
|
||||
|
||||
export async function shutdownTelemetry(): Promise<void> {
|
||||
await defaultTelemetryManager.shutdown();
|
||||
}
|
||||
|
||||
export function getTelemetryConfig(): TelemetryConfig | null {
|
||||
return defaultTelemetryManager.getConfig();
|
||||
}
|
||||
|
||||
export function getOtlpConfig(): OtlpConfig | null {
|
||||
return defaultTelemetryManager.getOtlpConfig();
|
||||
}
|
||||
90
packages/telemetry/src/Tracing.tsx
Normal file
90
packages/telemetry/src/Tracing.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Attributes, Span} from '@opentelemetry/api';
|
||||
import {SpanStatusCode, trace} from '@opentelemetry/api';
|
||||
|
||||
const DEFAULT_TRACER_NAME = 'fluxer';
|
||||
|
||||
export interface WithSpanOptions {
|
||||
attributes?: Attributes;
|
||||
tracerName?: string;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
export function getTracer(name: string = DEFAULT_TRACER_NAME) {
|
||||
return trace.getTracer(name);
|
||||
}
|
||||
|
||||
export async function withSpan<T>(
|
||||
name: string,
|
||||
fn: (span: Span) => Promise<T> | T,
|
||||
options?: WithSpanOptions,
|
||||
): Promise<T> {
|
||||
const tracer = getTracer(options?.tracerName);
|
||||
const spanOptions = options?.attributes === undefined ? {} : {attributes: options.attributes};
|
||||
return tracer.startActiveSpan(name, spanOptions, async (span) => {
|
||||
try {
|
||||
const result = await fn(span);
|
||||
span.setStatus({code: SpanStatusCode.OK});
|
||||
return result;
|
||||
} catch (error) {
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}) as Promise<T>;
|
||||
}
|
||||
|
||||
export function addSpanEvent(name: string, attributes?: Attributes): void {
|
||||
const activeSpan = trace.getActiveSpan();
|
||||
if (activeSpan !== undefined) {
|
||||
activeSpan.addEvent(name, attributes);
|
||||
}
|
||||
}
|
||||
|
||||
export function setSpanAttributes(attributes: Attributes): void {
|
||||
const activeSpan = trace.getActiveSpan();
|
||||
if (activeSpan !== undefined) {
|
||||
activeSpan.setAttributes(attributes);
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveSpan(): Span | undefined {
|
||||
return trace.getActiveSpan();
|
||||
}
|
||||
|
||||
export function formatTraceparent(span: Span): string | null {
|
||||
const spanContext = span.spanContext();
|
||||
if (!spanContext.traceId || !spanContext.spanId) {
|
||||
return null;
|
||||
}
|
||||
const traceFlags = spanContext.traceFlags?.toString(16).padStart(2, '0') ?? '00';
|
||||
return `00-${spanContext.traceId}-${spanContext.spanId}-${traceFlags}`;
|
||||
}
|
||||
117
packages/telemetry/src/telemetry_runtime/TelemetryConfig.tsx
Normal file
117
packages/telemetry/src/telemetry_runtime/TelemetryConfig.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {hostname} from 'node:os';
|
||||
import type {OtlpConfig, TelemetryConfig} from '@fluxer/telemetry/src/Telemetry';
|
||||
|
||||
function assertPositiveNumber(value: number, fieldName: string): void {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`Telemetry config "${fieldName}" must be a positive number`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEndpoint(endpoint: string): string {
|
||||
const trimmedEndpoint = endpoint.trim();
|
||||
return trimmedEndpoint.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function normalizeIgnoreIncomingPaths(paths: Array<string>): Array<string> {
|
||||
const pathSet = new Set<string>();
|
||||
for (const path of paths) {
|
||||
const normalizedPath = path.trim();
|
||||
if (normalizedPath.length === 0) {
|
||||
continue;
|
||||
}
|
||||
pathSet.add(normalizedPath);
|
||||
}
|
||||
return [...pathSet];
|
||||
}
|
||||
|
||||
function validateTelemetryConfig(config: TelemetryConfig): void {
|
||||
if (config.serviceName.trim().length === 0) {
|
||||
throw new Error('Telemetry config "serviceName" must not be empty');
|
||||
}
|
||||
if (config.serviceVersion.trim().length === 0) {
|
||||
throw new Error('Telemetry config "serviceVersion" must not be empty');
|
||||
}
|
||||
if (config.environment.trim().length === 0) {
|
||||
throw new Error('Telemetry config "environment" must not be empty');
|
||||
}
|
||||
if (config.enabled && config.otlpEndpoint.length === 0) {
|
||||
throw new Error('Telemetry config "otlpEndpoint" must not be empty when telemetry is enabled');
|
||||
}
|
||||
if (!Number.isFinite(config.traceSamplingRatio) || config.traceSamplingRatio < 0 || config.traceSamplingRatio > 1) {
|
||||
throw new Error('Telemetry config "traceSamplingRatio" must be between 0 and 1');
|
||||
}
|
||||
assertPositiveNumber(config.exportTimeout, 'exportTimeout');
|
||||
assertPositiveNumber(config.metricExportIntervalMs, 'metricExportIntervalMs');
|
||||
}
|
||||
|
||||
export function getDefaultTelemetryConfig(): TelemetryConfig {
|
||||
return {
|
||||
enabled: false,
|
||||
serviceName: 'fluxer',
|
||||
serviceVersion: 'unknown',
|
||||
serviceInstanceId: hostname(),
|
||||
environment: 'development',
|
||||
traceSamplingRatio: 1.0,
|
||||
otlpEndpoint: '',
|
||||
exportTimeout: 30000,
|
||||
metricExportIntervalMs: 60000,
|
||||
ignoreIncomingPaths: ['/_health'],
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTelemetryConfig(config?: Partial<TelemetryConfig>): TelemetryConfig {
|
||||
const defaults = getDefaultTelemetryConfig();
|
||||
const defined = Object.fromEntries(
|
||||
Object.entries(config ?? {}).filter(([, v]) => v !== undefined),
|
||||
) as Partial<TelemetryConfig>;
|
||||
const mergedConfig: TelemetryConfig = {
|
||||
...defaults,
|
||||
...defined,
|
||||
otlpEndpoint: normalizeEndpoint(config?.otlpEndpoint ?? defaults.otlpEndpoint),
|
||||
ignoreIncomingPaths: normalizeIgnoreIncomingPaths(config?.ignoreIncomingPaths ?? defaults.ignoreIncomingPaths),
|
||||
instrumentations: config?.instrumentations === undefined ? defaults.instrumentations : {...config.instrumentations},
|
||||
};
|
||||
validateTelemetryConfig(mergedConfig);
|
||||
return mergedConfig;
|
||||
}
|
||||
|
||||
export function cloneTelemetryConfig(config: TelemetryConfig): TelemetryConfig {
|
||||
return {
|
||||
...config,
|
||||
ignoreIncomingPaths: [...config.ignoreIncomingPaths],
|
||||
instrumentations: config.instrumentations === undefined ? undefined : {...config.instrumentations},
|
||||
};
|
||||
}
|
||||
|
||||
export function createOtlpConfigFromTelemetryConfig(config: TelemetryConfig): OtlpConfig {
|
||||
const baseEndpoint = config.otlpEndpoint.replace(/\/+$/, '');
|
||||
const headers = config.otlpApiKey === undefined ? undefined : {Authorization: `Bearer ${config.otlpApiKey}`};
|
||||
|
||||
return {
|
||||
endpoints: {
|
||||
traces: `${baseEndpoint}/v1/traces`,
|
||||
metrics: `${baseEndpoint}/v1/metrics`,
|
||||
logs: `${baseEndpoint}/v1/logs`,
|
||||
},
|
||||
...(headers !== undefined && {headers}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {InstrumentationConfig, TelemetryConfig} from '@fluxer/telemetry/src/Telemetry';
|
||||
import type {ITelemetryInitLogger} from '@fluxer/telemetry/src/telemetry_runtime/TelemetryLogger';
|
||||
import type {Instrumentation} from '@opentelemetry/instrumentation';
|
||||
import {HttpInstrumentation} from '@opentelemetry/instrumentation-http';
|
||||
import {PinoInstrumentation} from '@opentelemetry/instrumentation-pino';
|
||||
|
||||
interface InstrumentationModule {
|
||||
[key: string]: new (...args: Array<unknown>) => Instrumentation;
|
||||
}
|
||||
|
||||
interface OptionalInstrumentationDefinition {
|
||||
enabled: boolean;
|
||||
moduleName: string;
|
||||
className: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function getOptionalInstrumentationDefinitions(
|
||||
config: InstrumentationConfig,
|
||||
): Array<OptionalInstrumentationDefinition> {
|
||||
return [
|
||||
{
|
||||
enabled: config.fetch !== false,
|
||||
moduleName: '@opentelemetry/instrumentation-fetch',
|
||||
className: 'FetchInstrumentation',
|
||||
},
|
||||
{
|
||||
enabled: config.cassandra === true,
|
||||
moduleName: '@opentelemetry/instrumentation-cassandra-driver',
|
||||
className: 'CassandraDriverInstrumentation',
|
||||
},
|
||||
{
|
||||
enabled: config.aws === true,
|
||||
moduleName: '@opentelemetry/instrumentation-aws-sdk',
|
||||
className: 'AwsInstrumentation',
|
||||
config: {suppressInternalInstrumentation: true},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function loadOptionalInstrumentation(
|
||||
definition: OptionalInstrumentationDefinition,
|
||||
logger: ITelemetryInitLogger,
|
||||
): Promise<Instrumentation | null> {
|
||||
try {
|
||||
const module = (await import(definition.moduleName)) as InstrumentationModule;
|
||||
const InstrumentationClass = module[definition.className];
|
||||
if (typeof InstrumentationClass !== 'function') {
|
||||
logger.info(
|
||||
{moduleName: definition.moduleName, className: definition.className},
|
||||
'Optional instrumentation class not found',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (definition.config === undefined) {
|
||||
return new (InstrumentationClass as new () => Instrumentation)();
|
||||
}
|
||||
return new (InstrumentationClass as new (config: Record<string, unknown>) => Instrumentation)(definition.config);
|
||||
} catch (error) {
|
||||
logger.info(
|
||||
{moduleName: definition.moduleName, className: definition.className, error: formatError(error)},
|
||||
'Optional instrumentation not available',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTelemetryInstrumentations(
|
||||
config: TelemetryConfig,
|
||||
logger: ITelemetryInitLogger,
|
||||
): Promise<Array<Instrumentation>> {
|
||||
const ignoredIncomingPaths = new Set(config.ignoreIncomingPaths);
|
||||
const instrumentations: Array<Instrumentation> = [
|
||||
new HttpInstrumentation({
|
||||
ignoreIncomingRequestHook: (request) => {
|
||||
const path = request.url?.split('?')[0];
|
||||
if (path === undefined) {
|
||||
return false;
|
||||
}
|
||||
return ignoredIncomingPaths.has(path);
|
||||
},
|
||||
}),
|
||||
new PinoInstrumentation(),
|
||||
];
|
||||
|
||||
const optionalInstrumentationConfig = config.instrumentations ?? {};
|
||||
const optionalInstrumentationDefinitions = getOptionalInstrumentationDefinitions(optionalInstrumentationConfig);
|
||||
for (const definition of optionalInstrumentationDefinitions) {
|
||||
if (!definition.enabled) {
|
||||
continue;
|
||||
}
|
||||
const instrumentation = await loadOptionalInstrumentation(definition, logger);
|
||||
if (instrumentation !== null) {
|
||||
instrumentations.push(instrumentation);
|
||||
}
|
||||
}
|
||||
|
||||
return instrumentations;
|
||||
}
|
||||
37
packages/telemetry/src/telemetry_runtime/TelemetryLogger.tsx
Normal file
37
packages/telemetry/src/telemetry_runtime/TelemetryLogger.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface ITelemetryInitLogger {
|
||||
info(msg: string): void;
|
||||
info(obj: Record<string, unknown>, msg: string): void;
|
||||
}
|
||||
|
||||
export function createDefaultTelemetryLogger(): ITelemetryInitLogger {
|
||||
return {
|
||||
info(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
if (typeof objOrMsg === 'string') {
|
||||
process.stdout.write(`[telemetry] ${objOrMsg}\n`);
|
||||
return;
|
||||
}
|
||||
if (msg !== undefined) {
|
||||
process.stdout.write(`[telemetry] ${msg} ${JSON.stringify(objOrMsg)}\n`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
181
packages/telemetry/src/telemetry_runtime/TelemetryManager.tsx
Normal file
181
packages/telemetry/src/telemetry_runtime/TelemetryManager.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {OtlpConfig, TelemetryConfig} from '@fluxer/telemetry/src/Telemetry';
|
||||
import {
|
||||
cloneTelemetryConfig,
|
||||
createOtlpConfigFromTelemetryConfig,
|
||||
getDefaultTelemetryConfig,
|
||||
resolveTelemetryConfig,
|
||||
} from '@fluxer/telemetry/src/telemetry_runtime/TelemetryConfig';
|
||||
import type {ITelemetryInitLogger} from '@fluxer/telemetry/src/telemetry_runtime/TelemetryLogger';
|
||||
import {createTelemetrySdk} from '@fluxer/telemetry/src/telemetry_runtime/TelemetrySdk';
|
||||
import type {NodeSDK} from '@opentelemetry/sdk-node';
|
||||
|
||||
export interface ITelemetryRuntimeManager {
|
||||
initialize(config?: Partial<TelemetryConfig>): Promise<void>;
|
||||
shutdown(): Promise<void>;
|
||||
isActive(): boolean;
|
||||
shouldInitialize(): boolean;
|
||||
getConfig(): TelemetryConfig | null;
|
||||
getOtlpConfig(): OtlpConfig | null;
|
||||
}
|
||||
|
||||
export interface TelemetryRuntimeManagerOptions {
|
||||
logger: ITelemetryInitLogger;
|
||||
shouldInitialize: () => boolean;
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
class TelemetryRuntimeManager implements ITelemetryRuntimeManager {
|
||||
private sdk: NodeSDK | null = null;
|
||||
private config: TelemetryConfig | null = null;
|
||||
private initializationTask: Promise<void> | null = null;
|
||||
private shutdownTask: Promise<void> | null = null;
|
||||
private readonly logger: ITelemetryInitLogger;
|
||||
private readonly shouldInitializePredicate: () => boolean;
|
||||
|
||||
public constructor(options: TelemetryRuntimeManagerOptions) {
|
||||
this.logger = options.logger;
|
||||
this.shouldInitializePredicate = options.shouldInitialize;
|
||||
}
|
||||
|
||||
public isActive(): boolean {
|
||||
return this.sdk !== null;
|
||||
}
|
||||
|
||||
public shouldInitialize(): boolean {
|
||||
return this.shouldInitializePredicate();
|
||||
}
|
||||
|
||||
public getConfig(): TelemetryConfig | null {
|
||||
if (this.config === null) {
|
||||
return null;
|
||||
}
|
||||
return cloneTelemetryConfig(this.config);
|
||||
}
|
||||
|
||||
public getOtlpConfig(): OtlpConfig | null {
|
||||
if (!this.shouldInitialize()) {
|
||||
return null;
|
||||
}
|
||||
const config = this.config ?? getDefaultTelemetryConfig();
|
||||
return createOtlpConfigFromTelemetryConfig(config);
|
||||
}
|
||||
|
||||
public async initialize(config?: Partial<TelemetryConfig>): Promise<void> {
|
||||
if (this.shutdownTask !== null) {
|
||||
await this.shutdownTask;
|
||||
}
|
||||
if (this.sdk !== null) {
|
||||
this.logger.info('Telemetry already initialized');
|
||||
return;
|
||||
}
|
||||
if (this.initializationTask !== null) {
|
||||
await this.initializationTask;
|
||||
return;
|
||||
}
|
||||
if (!this.shouldInitialize()) {
|
||||
this.logger.info('Telemetry disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedConfig = resolveTelemetryConfig(config);
|
||||
if (!resolvedConfig.enabled) {
|
||||
this.config = resolvedConfig;
|
||||
this.logger.info('Telemetry disabled by configuration');
|
||||
return;
|
||||
}
|
||||
|
||||
const initializationTask = this.initializeInternal(resolvedConfig);
|
||||
this.initializationTask = initializationTask;
|
||||
try {
|
||||
await initializationTask;
|
||||
} finally {
|
||||
if (this.initializationTask === initializationTask) {
|
||||
this.initializationTask = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async shutdown(): Promise<void> {
|
||||
if (this.initializationTask !== null) {
|
||||
await this.initializationTask;
|
||||
}
|
||||
if (this.shutdownTask !== null) {
|
||||
await this.shutdownTask;
|
||||
return;
|
||||
}
|
||||
|
||||
const shutdownTask = this.shutdownInternal();
|
||||
this.shutdownTask = shutdownTask;
|
||||
try {
|
||||
await shutdownTask;
|
||||
} finally {
|
||||
if (this.shutdownTask === shutdownTask) {
|
||||
this.shutdownTask = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeInternal(config: TelemetryConfig): Promise<void> {
|
||||
this.logger.info(
|
||||
{
|
||||
serviceName: config.serviceName,
|
||||
environment: config.environment,
|
||||
endpoint: config.otlpEndpoint,
|
||||
traceSamplingRatio: config.traceSamplingRatio,
|
||||
},
|
||||
'Initializing telemetry',
|
||||
);
|
||||
|
||||
const sdk = await createTelemetrySdk(config, this.logger);
|
||||
try {
|
||||
await Promise.resolve(sdk.start());
|
||||
} catch (error) {
|
||||
await Promise.resolve(sdk.shutdown()).catch(() => undefined);
|
||||
this.logger.info({error: formatError(error)}, 'Telemetry initialization failed');
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.sdk = sdk;
|
||||
this.config = cloneTelemetryConfig(config);
|
||||
this.logger.info('Telemetry initialized successfully');
|
||||
}
|
||||
|
||||
private async shutdownInternal(): Promise<void> {
|
||||
const sdk = this.sdk;
|
||||
this.sdk = null;
|
||||
this.config = null;
|
||||
if (sdk !== null) {
|
||||
await sdk.shutdown();
|
||||
}
|
||||
this.logger.info('Telemetry shutdown complete');
|
||||
}
|
||||
}
|
||||
|
||||
export function createTelemetryRuntimeManager(options: TelemetryRuntimeManagerOptions): ITelemetryRuntimeManager {
|
||||
return new TelemetryRuntimeManager(options);
|
||||
}
|
||||
82
packages/telemetry/src/telemetry_runtime/TelemetrySdk.tsx
Normal file
82
packages/telemetry/src/telemetry_runtime/TelemetrySdk.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {TelemetryConfig} from '@fluxer/telemetry/src/Telemetry';
|
||||
import {createOtlpConfigFromTelemetryConfig} from '@fluxer/telemetry/src/telemetry_runtime/TelemetryConfig';
|
||||
import {createTelemetryInstrumentations} from '@fluxer/telemetry/src/telemetry_runtime/TelemetryInstrumentations';
|
||||
import type {ITelemetryInitLogger} from '@fluxer/telemetry/src/telemetry_runtime/TelemetryLogger';
|
||||
import {OTLPLogExporter} from '@opentelemetry/exporter-logs-otlp-http';
|
||||
import {OTLPMetricExporter} from '@opentelemetry/exporter-metrics-otlp-http';
|
||||
import {OTLPTraceExporter} from '@opentelemetry/exporter-trace-otlp-http';
|
||||
import {resourceFromAttributes} from '@opentelemetry/resources';
|
||||
import {BatchLogRecordProcessor} from '@opentelemetry/sdk-logs';
|
||||
import {PeriodicExportingMetricReader} from '@opentelemetry/sdk-metrics';
|
||||
import {NodeSDK} from '@opentelemetry/sdk-node';
|
||||
import {ParentBasedSampler, TraceIdRatioBasedSampler} from '@opentelemetry/sdk-trace-base';
|
||||
import {
|
||||
SEMRESATTRS_DEPLOYMENT_ENVIRONMENT,
|
||||
SEMRESATTRS_SERVICE_INSTANCE_ID,
|
||||
SEMRESATTRS_SERVICE_NAME,
|
||||
SEMRESATTRS_SERVICE_NAMESPACE,
|
||||
SEMRESATTRS_SERVICE_VERSION,
|
||||
} from '@opentelemetry/semantic-conventions';
|
||||
|
||||
export async function createTelemetrySdk(config: TelemetryConfig, logger: ITelemetryInitLogger): Promise<NodeSDK> {
|
||||
const otlpConfig = createOtlpConfigFromTelemetryConfig(config);
|
||||
const resource = resourceFromAttributes({
|
||||
[SEMRESATTRS_SERVICE_NAME]: config.serviceName,
|
||||
[SEMRESATTRS_SERVICE_NAMESPACE]: 'fluxer',
|
||||
[SEMRESATTRS_SERVICE_VERSION]: config.serviceVersion,
|
||||
[SEMRESATTRS_SERVICE_INSTANCE_ID]: config.serviceInstanceId,
|
||||
[SEMRESATTRS_DEPLOYMENT_ENVIRONMENT]: config.environment,
|
||||
});
|
||||
|
||||
const traceExporter = new OTLPTraceExporter({
|
||||
url: otlpConfig.endpoints.traces,
|
||||
...(otlpConfig.headers !== undefined && {headers: otlpConfig.headers}),
|
||||
timeoutMillis: config.exportTimeout,
|
||||
});
|
||||
const metricExporter = new OTLPMetricExporter({
|
||||
url: otlpConfig.endpoints.metrics,
|
||||
...(otlpConfig.headers !== undefined && {headers: otlpConfig.headers}),
|
||||
timeoutMillis: config.exportTimeout,
|
||||
});
|
||||
const logExporter = new OTLPLogExporter({
|
||||
url: otlpConfig.endpoints.logs,
|
||||
...(otlpConfig.headers !== undefined && {headers: otlpConfig.headers}),
|
||||
timeoutMillis: config.exportTimeout,
|
||||
});
|
||||
|
||||
const sampler = new ParentBasedSampler({
|
||||
root: new TraceIdRatioBasedSampler(config.traceSamplingRatio),
|
||||
});
|
||||
const instrumentations = await createTelemetryInstrumentations(config, logger);
|
||||
|
||||
return new NodeSDK({
|
||||
resource,
|
||||
traceExporter,
|
||||
sampler,
|
||||
metricReader: new PeriodicExportingMetricReader({
|
||||
exporter: metricExporter,
|
||||
exportIntervalMillis: config.metricExportIntervalMs,
|
||||
}),
|
||||
logRecordProcessors: [new BatchLogRecordProcessor(logExporter)],
|
||||
instrumentations,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user