From 68c7430fbb3d158c88f95beeb5881352d1d4caf1 Mon Sep 17 00:00:00 2001 From: Nya Candy Date: Fri, 5 Jul 2024 19:22:33 +0800 Subject: [PATCH] feat(core): opentelementry support (update of #14668) (#16087) * chore: squash commit * fix: tweak some mentioned issues --------- Co-authored-by: incubator4 --- lib/app.tsx | 2 + lib/errors/index.tsx | 3 + lib/middleware/logger.ts | 6 +- lib/middleware/trace.ts | 25 ++++++ lib/registry.ts | 6 ++ lib/routes/metrics.ts | 12 +++ lib/utils/otel/index.ts | 2 + lib/utils/otel/metric.ts | 66 ++++++++++++++++ lib/utils/otel/trace.ts | 28 +++++++ package.json | 7 ++ pnpm-lock.yaml | 159 +++++++++++++++++++++++++++++++++++++++ 11 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 lib/middleware/trace.ts create mode 100644 lib/routes/metrics.ts create mode 100644 lib/utils/otel/index.ts create mode 100644 lib/utils/otel/metric.ts create mode 100644 lib/utils/otel/trace.ts diff --git a/lib/app.tsx b/lib/app.tsx index 65feef3ab..2747dcfd2 100644 --- a/lib/app.tsx +++ b/lib/app.tsx @@ -12,6 +12,7 @@ import debug from '@/middleware/debug'; import header from '@/middleware/header'; import antiHotlink from '@/middleware/anti-hotlink'; import parameter from '@/middleware/parameter'; +import trace from '@/middleware/trace'; import { jsxRenderer } from 'hono/jsx-renderer'; import { trimTrailingSlash } from 'hono/trailing-slash'; @@ -37,6 +38,7 @@ app.use( }) ); app.use(mLogger); +app.use(trace); app.use(sentry); app.use(accessControl); app.use(debug); diff --git a/lib/errors/index.tsx b/lib/errors/index.tsx index dd996a7f4..7dd491896 100644 --- a/lib/errors/index.tsx +++ b/lib/errors/index.tsx @@ -7,6 +7,8 @@ import Error from '@/views/error'; import NotFoundError from './types/not-found'; +import { requestMetric } from '@/utils/otel'; + export const errorHandler: ErrorHandler = (error, ctx) => { const requestPath = ctx.req.path; const matchedRoute = ctx.req.routePath; @@ -61,6 +63,7 @@ export const errorHandler: ErrorHandler = (error, ctx) => { const message = `${error.name}: ${errorMessage}`; logger.error(`Error in ${requestPath}: ${message}`); + requestMetric.error({ path: requestPath, method: ctx.req.method, status: ctx.res.status }); return config.isPackage ? ctx.json({ diff --git a/lib/middleware/logger.ts b/lib/middleware/logger.ts index f806e7ee0..17ba3adf3 100644 --- a/lib/middleware/logger.ts +++ b/lib/middleware/logger.ts @@ -1,3 +1,4 @@ +import { requestMetric } from '@/utils/otel'; import { MiddlewareHandler } from 'hono'; import logger from '@/utils/logger'; import { getPath, time } from '@/utils/helpers'; @@ -34,7 +35,10 @@ const middleware: MiddlewareHandler = async (ctx, next) => { await next(); - logger.info(`${LogPrefix.Outgoing} ${method} ${path} ${colorStatus(ctx.res.status)} ${time(start)}`); + const status = ctx.res.status; + + logger.info(`${LogPrefix.Outgoing} ${method} ${path} ${colorStatus(status)} ${time(start)}`); + requestMetric.success(Date.now() - start, { path, method, status }); }; export default middleware; diff --git a/lib/middleware/trace.ts b/lib/middleware/trace.ts new file mode 100644 index 000000000..78fa81e44 --- /dev/null +++ b/lib/middleware/trace.ts @@ -0,0 +1,25 @@ +import { MiddlewareHandler } from 'hono'; +import { getPath } from '@/utils/helpers'; +import { config } from '@/config'; +import { tracer } from '@/utils/otel'; + +const middleware: MiddlewareHandler = async (ctx, next) => { + if (config.debugInfo) { + // Only enable tracing in debug mode + const { method, raw } = ctx.req; + const path = getPath(raw); + + const span = tracer.startSpan(`${method} ${path}`, { + kind: 1, // server + attributes: {}, + }); + span.addEvent('invoking handleRequest'); + await next(); + span.end(); + } else { + // Skip + await next(); + } +}; + +export default middleware; diff --git a/lib/registry.ts b/lib/registry.ts index b984908fa..0eb993518 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -4,9 +4,11 @@ import { Hono, type Handler } from 'hono'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { serveStatic } from '@hono/node-server/serve-static'; +import { config } from '@/config'; import index from '@/routes/index'; import robotstxt from '@/routes/robots.txt'; +import metrics from '@/routes/metrics'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -99,6 +101,10 @@ for (const namespace in namespaces) { app.get('/', index); app.get('/robots.txt', robotstxt); +if (config.debugInfo) { + // Only enable tracing in debug mode + app.get('/metrics', metrics); +} app.use( '/*', serveStatic({ diff --git a/lib/routes/metrics.ts b/lib/routes/metrics.ts new file mode 100644 index 000000000..c76249748 --- /dev/null +++ b/lib/routes/metrics.ts @@ -0,0 +1,12 @@ +import type { Handler } from 'hono'; +import { getContext } from '@/utils/otel'; + +const handler: Handler = (ctx) => + getContext() + .then((val) => ctx.text(val)) + .catch((error) => { + ctx.status(500); + ctx.json({ error }); + }); + +export default handler; diff --git a/lib/utils/otel/index.ts b/lib/utils/otel/index.ts new file mode 100644 index 000000000..7649f22db --- /dev/null +++ b/lib/utils/otel/index.ts @@ -0,0 +1,2 @@ +export * from './metric'; +export * from './trace'; diff --git a/lib/utils/otel/metric.ts b/lib/utils/otel/metric.ts new file mode 100644 index 000000000..bfffd5fdb --- /dev/null +++ b/lib/utils/otel/metric.ts @@ -0,0 +1,66 @@ +import { Resource } from '@opentelemetry/resources'; +import { PrometheusExporter, PrometheusSerializer } from '@opentelemetry/exporter-prometheus'; +import { SEMRESATTRS_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; +import { MeterProvider } from '@opentelemetry/sdk-metrics'; +import { Attributes } from '@opentelemetry/api'; + +interface IMetricAttributes extends Attributes { + method: string; + path: string; + status: number; +} + +interface IHistogramAttributes extends IMetricAttributes { + unit: string; +} + +const METRIC_PREFIX = 'rsshub'; + +const exporter = new PrometheusExporter({}); + +const provider = new MeterProvider({ + resource: new Resource({ + [SEMRESATTRS_SERVICE_NAME]: 'rsshub', + }), + readers: [exporter], +}); + +const serializer = new PrometheusSerializer(); + +const meter = provider.getMeter('rsshub'); + +const requestTotal = meter.createCounter(`${METRIC_PREFIX}_request_total`); +const requestErrorTotal = meter.createCounter(`${METRIC_PREFIX}_request_error_total`); +const requestDurationSecondsBucket = meter.createHistogram(`${METRIC_PREFIX}_request_duration_seconds_bucket`, { + advice: { + explicitBucketBoundaries: [0.01, 0.1, 1, 2, 5, 15, 30, 60], + }, +}); +const request_duration_milliseconds_bucket = meter.createHistogram(`${METRIC_PREFIX}_request_duration_milliseconds_bucket`, { + advice: { + explicitBucketBoundaries: [10, 20, 50, 100, 250, 500, 1000, 5000, 15000], + }, +}); + +export const requestMetric = { + success: (value: number, attributes: IMetricAttributes) => { + requestTotal.add(1, attributes); + request_duration_milliseconds_bucket.record(value, { unit: 'millisecond', ...attributes }); + requestDurationSecondsBucket.record(value / 1000, { unit: 'second', ...attributes }); + }, + error: (attributes: IMetricAttributes) => { + requestErrorTotal.add(1, attributes); + }, +}; + +export const getContext = () => + new Promise((resolve, reject) => { + exporter + .collect() + .then((value) => { + resolve(serializer.serialize(value.resourceMetrics)); + }) + .finally(() => { + reject(''); + }); + }); diff --git a/lib/utils/otel/trace.ts b/lib/utils/otel/trace.ts new file mode 100644 index 000000000..d497f28b1 --- /dev/null +++ b/lib/utils/otel/trace.ts @@ -0,0 +1,28 @@ +import { Resource } from '@opentelemetry/resources'; +import { BasicTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { SEMRESATTRS_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; + +const provider = new BasicTracerProvider({ + resource: new Resource({ + [SEMRESATTRS_SERVICE_NAME]: 'rsshub', + }), +}); + +const exporter = new OTLPTraceExporter({ + // optional OTEL_EXPORTER_OTLP_ENDPOINT=https://localhost:4318 +}); + +provider.addSpanProcessor( + new BatchSpanProcessor(exporter, { + // The maximum queue size. After the size is reached spans are dropped. + maxQueueSize: 4096, + // The interval between two consecutive exports + scheduledDelayMillis: 30000, + }) +); + +provider.register(); + +export const tracer = provider.getTracer('rsshub'); +export const mainSpan = tracer.startSpan('main'); diff --git a/package.json b/package.json index 84fed503e..a3b609e4c 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,13 @@ "@hono/node-server": "1.12.0", "@hono/zod-openapi": "0.14.7", "@notionhq/client": "2.2.15", + "@opentelemetry/api": "1.8.0", + "@opentelemetry/exporter-prometheus": "0.49.1", + "@opentelemetry/exporter-trace-otlp-http": "0.49.1", + "@opentelemetry/resources": "1.22.0", + "@opentelemetry/sdk-metrics": "1.22.0", + "@opentelemetry/sdk-trace-base": "1.22.0", + "@opentelemetry/semantic-conventions": "1.22.0", "@postlight/parser": "2.2.3", "@scalar/hono-api-reference": "0.5.94", "@sentry/node": "7.116.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10e4e6a0f..e4f183100 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,27 @@ importers: '@notionhq/client': specifier: 2.2.15 version: 2.2.15 + '@opentelemetry/api': + specifier: 1.8.0 + version: 1.8.0 + '@opentelemetry/exporter-prometheus': + specifier: 0.49.1 + version: 0.49.1(@opentelemetry/api@1.8.0) + '@opentelemetry/exporter-trace-otlp-http': + specifier: 0.49.1 + version: 0.49.1(@opentelemetry/api@1.8.0) + '@opentelemetry/resources': + specifier: 1.22.0 + version: 1.22.0(@opentelemetry/api@1.8.0) + '@opentelemetry/sdk-metrics': + specifier: 1.22.0 + version: 1.22.0(@opentelemetry/api@1.8.0) + '@opentelemetry/sdk-trace-base': + specifier: 1.22.0 + version: 1.22.0(@opentelemetry/api@1.8.0) + '@opentelemetry/semantic-conventions': + specifier: 1.22.0 + version: 1.22.0 '@postlight/parser': specifier: 2.2.3 version: 2.2.3 @@ -1426,6 +1447,73 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@opentelemetry/api-logs@0.49.1': + resolution: {integrity: sha512-kaNl/T7WzyMUQHQlVq7q0oV4Kev6+0xFwqzofryC66jgGMacd0QH5TwfpbUwSTby+SdAdprAe5UKMvBw4tKS5Q==} + engines: {node: '>=14'} + + '@opentelemetry/api@1.8.0': + resolution: {integrity: sha512-I/s6F7yKUDdtMsoBWXJe8Qz40Tui5vsuKCWJEWVL+5q9sSWRzzx6v2KeNsOBEwd94j0eWkpWCH4yB6rZg9Mf0w==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@1.22.0': + resolution: {integrity: sha512-0VoAlT6x+Xzik1v9goJ3pZ2ppi6+xd3aUfg4brfrLkDBHRIVjMP0eBHrKrhB+NKcDyMAg8fAbGL3Npg/F6AwWA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.9.0' + + '@opentelemetry/exporter-prometheus@0.49.1': + resolution: {integrity: sha512-FgzGl6OH22f+Wb1dh/TnoQSnZE2SCADhHx06nMqxivSqRJ9t3AhUdMsEOFt2IMjZClE705pcsLHk10BCJ79vsA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.49.1': + resolution: {integrity: sha512-KOLtZfZvIrpGZLVvblKsiVQT7gQUZNKcUUH24Zz6Xbi7LJb9Vt6xtUZFYdR5IIjvt47PIqBKDWUQlU0o1wAsRw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/otlp-exporter-base@0.49.1': + resolution: {integrity: sha512-z6sHliPqDgJU45kQatAettY9/eVF58qVPaTuejw9YWfSRqid9pXPYeegDCSdyS47KAUgAtm+nC28K3pfF27HWg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/otlp-transformer@0.49.1': + resolution: {integrity: sha512-Z+koA4wp9L9e3jkFacyXTGphSWTbOKjwwXMpb0CxNb0kjTHGUxhYRN8GnkLFsFo5NbZPjP07hwAqeEG/uCratQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.9.0' + + '@opentelemetry/resources@1.22.0': + resolution: {integrity: sha512-+vNeIFPH2hfcNL0AJk/ykJXoUCtR1YaDUZM+p3wZNU4Hq98gzq+7b43xbkXjadD9VhWIUQqEwXyY64q6msPj6A==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.9.0' + + '@opentelemetry/sdk-logs@0.49.1': + resolution: {integrity: sha512-gCzYWsJE0h+3cuh3/cK+9UwlVFyHvj3PReIOCDOmdeXOp90ZjKRoDOJBc3mvk1LL6wyl1RWIivR8Rg9OToyesw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.9.0' + '@opentelemetry/api-logs': '>=0.39.1' + + '@opentelemetry/sdk-metrics@1.22.0': + resolution: {integrity: sha512-k6iIx6H3TZ+BVMr2z8M16ri2OxWaljg5h8ihGJxi/KQWcjign6FEaEzuigXt5bK9wVEhqAcWLCfarSftaNWkkg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.9.0' + + '@opentelemetry/sdk-trace-base@1.22.0': + resolution: {integrity: sha512-pfTuSIpCKONC6vkTpv6VmACxD+P1woZf4q0K46nSUvXFvOFqjBYKFaAMkKD3M1mlKUUh0Oajwj35qNjMl80m1Q==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.9.0' + + '@opentelemetry/semantic-conventions@1.22.0': + resolution: {integrity: sha512-CAOgFOKLybd02uj/GhCdEeeBjOS0yeoDeo/CA7ASBSmenpZHAKGB3iDm/rv3BQLcabb/OprDEsSQ1y0P8A7Siw==} + engines: {node: '>=14'} + '@otplib/core@12.0.1': resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==} @@ -7824,6 +7912,77 @@ snapshots: '@open-draft/until@2.1.0': {} + '@opentelemetry/api-logs@0.49.1': + dependencies: + '@opentelemetry/api': 1.8.0 + + '@opentelemetry/api@1.8.0': {} + + '@opentelemetry/core@1.22.0(@opentelemetry/api@1.8.0)': + dependencies: + '@opentelemetry/api': 1.8.0 + '@opentelemetry/semantic-conventions': 1.22.0 + + '@opentelemetry/exporter-prometheus@0.49.1(@opentelemetry/api@1.8.0)': + dependencies: + '@opentelemetry/api': 1.8.0 + '@opentelemetry/core': 1.22.0(@opentelemetry/api@1.8.0) + '@opentelemetry/resources': 1.22.0(@opentelemetry/api@1.8.0) + '@opentelemetry/sdk-metrics': 1.22.0(@opentelemetry/api@1.8.0) + + '@opentelemetry/exporter-trace-otlp-http@0.49.1(@opentelemetry/api@1.8.0)': + dependencies: + '@opentelemetry/api': 1.8.0 + '@opentelemetry/core': 1.22.0(@opentelemetry/api@1.8.0) + '@opentelemetry/otlp-exporter-base': 0.49.1(@opentelemetry/api@1.8.0) + '@opentelemetry/otlp-transformer': 0.49.1(@opentelemetry/api@1.8.0) + '@opentelemetry/resources': 1.22.0(@opentelemetry/api@1.8.0) + '@opentelemetry/sdk-trace-base': 1.22.0(@opentelemetry/api@1.8.0) + + '@opentelemetry/otlp-exporter-base@0.49.1(@opentelemetry/api@1.8.0)': + dependencies: + '@opentelemetry/api': 1.8.0 + '@opentelemetry/core': 1.22.0(@opentelemetry/api@1.8.0) + + '@opentelemetry/otlp-transformer@0.49.1(@opentelemetry/api@1.8.0)': + dependencies: + '@opentelemetry/api': 1.8.0 + '@opentelemetry/api-logs': 0.49.1 + '@opentelemetry/core': 1.22.0(@opentelemetry/api@1.8.0) + '@opentelemetry/resources': 1.22.0(@opentelemetry/api@1.8.0) + '@opentelemetry/sdk-logs': 0.49.1(@opentelemetry/api-logs@0.49.1)(@opentelemetry/api@1.8.0) + '@opentelemetry/sdk-metrics': 1.22.0(@opentelemetry/api@1.8.0) + '@opentelemetry/sdk-trace-base': 1.22.0(@opentelemetry/api@1.8.0) + + '@opentelemetry/resources@1.22.0(@opentelemetry/api@1.8.0)': + dependencies: + '@opentelemetry/api': 1.8.0 + '@opentelemetry/core': 1.22.0(@opentelemetry/api@1.8.0) + '@opentelemetry/semantic-conventions': 1.22.0 + + '@opentelemetry/sdk-logs@0.49.1(@opentelemetry/api-logs@0.49.1)(@opentelemetry/api@1.8.0)': + dependencies: + '@opentelemetry/api': 1.8.0 + '@opentelemetry/api-logs': 0.49.1 + '@opentelemetry/core': 1.22.0(@opentelemetry/api@1.8.0) + '@opentelemetry/resources': 1.22.0(@opentelemetry/api@1.8.0) + + '@opentelemetry/sdk-metrics@1.22.0(@opentelemetry/api@1.8.0)': + dependencies: + '@opentelemetry/api': 1.8.0 + '@opentelemetry/core': 1.22.0(@opentelemetry/api@1.8.0) + '@opentelemetry/resources': 1.22.0(@opentelemetry/api@1.8.0) + lodash.merge: 4.6.2 + + '@opentelemetry/sdk-trace-base@1.22.0(@opentelemetry/api@1.8.0)': + dependencies: + '@opentelemetry/api': 1.8.0 + '@opentelemetry/core': 1.22.0(@opentelemetry/api@1.8.0) + '@opentelemetry/resources': 1.22.0(@opentelemetry/api@1.8.0) + '@opentelemetry/semantic-conventions': 1.22.0 + + '@opentelemetry/semantic-conventions@1.22.0': {} + '@otplib/core@12.0.1': {} '@otplib/plugin-crypto@12.0.1':