hotelme-nuxt-logger 1.5.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -163,6 +163,7 @@ logger: {
163
163
  enabled: true,
164
164
  ignorePaths: ['/nuxt_img/'], // + типові /_nuxt/, /_ipx/, /__nuxt, clientErrorEndpoint
165
165
  ignoreUserAgents: ['^Slackbot-LinkExpanding ', '^kube-probe/'],
166
+ slowMs: 1000, // порятунок повільних, env OTEL_TRACES_SLOW_MS
166
167
  },
167
168
  }
168
169
  ```
@@ -175,17 +176,38 @@ Env (читає SDK):
175
176
  | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` / `OTEL_EXPORTER_OTLP_ENDPOINT` | — (обов'язкова: без неї трейсинг не вмикається, лог `otel_disabled`) |
176
177
  | `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG` | `parentbased_always_on` |
177
178
 
179
+ Env модуля (перекриває `slowMs` з nuxt.config):
180
+
181
+ | Змінна | Типово |
182
+ |---|---|
183
+ | `OTEL_TRACES_SLOW_MS` | — (0 — без порятунку) |
184
+
185
+ Напр. 25% трейсів + повільні ≥ 1 с з решти:
186
+
187
+ ```yaml
188
+ OTEL_TRACES_SAMPLER: parentbased_traceidratio
189
+ OTEL_TRACES_SAMPLER_ARG: "0.25"
190
+ OTEL_TRACES_SLOW_MS: "1000"
191
+ ```
192
+
178
193
  Що робить модуль:
179
194
 
180
195
  - **Активний контекст на весь запит.** Плагін обгортає `nitroApp.h3App.handler`, тож middleware, handler'и та SSR-рендер виконуються всередині `context.with(serverSpan)` — span'и з коду застосунку стають дочірніми без обгорток.
181
196
  - **Server span** на кожен запит, батько — вхідний `traceparent`. Без нього trace id = `X-Request-ID` без дефісів (`2e285875-f20c-…` → `2e285875f20c…`, як у libwts3), якщо це UUID / 32 hex; інакше випадковий. У `event.context`: `otelSpan`; у відповіді — `X-Trace-Id`. 5xx → статус ERROR, 4xx — ні.
182
197
  - **Вихідні fetch** (native `fetch`, `$fetch`, ofetch на зовнішній URL) — `@opentelemetry/instrumentation-undici` через diagnostics_channel: CLIENT span `GET <host>` (повний URL — в `url.full`), `traceparent` і `X-Request-ID` сторінки, якщо виклик не поставив свій. Поза запитом span'и не пишуться.
183
- - **Внутрішні SSR-виклики** (`$fetch('/api/…')` → Nitro localFetch) ідуть у тому ж async-ланцюжку, тож span роуту стає дочірнім span'а сторінки, а logger-middleware бере reqid сторінки. Заголовки руками не потрібні.
198
+ - **Внутрішні SSR-виклики** (`$fetch('/api/…')` → Nitro localFetch) ідуть у тому ж async-ланцюжку, тож span роуту стає дочірнім span'а сторінки, а logger-middleware бере reqid сторінки. Заголовки руками не потрібні. Внутрішній підзапит на відфільтрований шлях (`/__nuxt_island/…`) span'а не отримує, але лишається в контексті сторінки — його `/api/…`-виклики стають прямими дітьми span'а сторінки.
184
199
  - **Назва span'а** — шаблон, ніколи сирий шлях (кожна назва — окремий stream у VictoriaTraces), визначається сама після роутингу:
185
200
  - сторінка → `route.name` з Vue Router без i18n-суфікса: `GET weather-citynameid`, `GET index` (аліаси теж). Локаль — в атрибуті `i18n.locale`;
186
201
  - Nitro-хендлер → шаблон з файлової структури: `GET /api/city_info`, `GET /record/**`;
187
202
  - решта (відповідь з server middleware, невідомий шлях) → `GET /api/*` / `GET /*`; точний шлях — в `url.path`, статус — в `http.response.status_code`.
188
- - **Фільтри** (статика, `ignorePaths`, `ignoreUserAgents`) не міняють поведінку запиту: він обробляється як завжди, але в невибраному (sampled=0) контексті — без span'ів у нього, його внутрішніх викликів і бекенда (`traceparent …-00`). Потрібен parent-based семплер (типовий).
203
+ - **Фільтри** (статика, `ignorePaths`, `ignoreUserAgents`) не міняють поведінку запиту: він обробляється як завжди, але в невибраному (sampled=0) контексті — без span'ів у нього, його внутрішніх викликів і бекенда (`traceparent …-00`; libwts3 з `OTEL_TRACES_SLOW_MS` все ж відправить свій повільний запит — без батька з цього боку). Потрібен parent-based семплер (типовий).
204
+ - **Вибірка «head + порятунок повільних»** (`slowMs` > 0 і семплер вибирає < 100%):
205
+ - частка з `OTEL_TRACES_SAMPLER_ARG` вибирається на старті root span'а за trace id, як у `parentbased_traceidratio` — `traceparent …-01` іде в бекенд, трейс повний. Підтримуються `always_on` / `always_off` / `traceidratio` (з `parentbased_` чи без — семплер завжди parent-based); інший семплер (`jaeger_remote` тощо) → warn `otel_config_invalid`, порятунок вимкнено;
206
+ - решта пишеться в пам'ять (`traceparent …-00`) і буферизується до кінця свого локального root span'а — окремо для кожного запиту, навіть з одним trace id. Root тривав ≥ `slowMs` → експортуються він (`sampling.kept_by: "slow"`) і всі його локальні дочірні span'и (`/api/*`, CLIENT span'и вихідних fetch з тривалістю); інакше буфер скидається. Так само працює `OTEL_TRACES_SLOW_MS` у libwts3;
207
+ - span'и бекенда в такому трейсі є, лише якщо бекенд врятував їх сам: libwts3 на `…-00` теж пише в пам'ять і відправляє запит, що тривав ≥ свого `OTEL_TRACES_SLOW_MS` (типово 1000). Повільний бекенд робить повільним і root тут, тож трейс зв'язний; швидкого бекенда в ньому немає;
208
+ - вхідний `traceparent …-01` — вибраний, `…-00` — та сама логіка порятунку; фільтри (статика, `ignorePaths`, `ignoreUserAgents`) не пишуться взагалі;
209
+ - буфер обмежено: 2000 трейсів у польоті, 512 span'ів на трейс.
210
+ - **`?force_otel=1`** у запиті (як у libwts3) — трейс вибраний завжди: попри частку семплера, фільтри (`ignorePaths`, `ignoreUserAgents`, статика) і вхідний `traceparent …-00`. Позначка в контексті, тож вибрані й дочірні span'и (внутрішні `/api/*`, вихідні fetch), а бекенд отримує `traceparent …-01` і пише свою частину повністю. Root — з `sampling.kept_by: "force"`. Працює з `always_*` / `traceidratio` (з `parentbased_` чи без); з іншим семплером (`jaeger_remote`) параметр ігнорується, `otel_initialized` пише `force_otel: false`. Параметр публічний: будь-хто може форсувати трейс — як і в libwts3.
189
211
  - **Flush при зупинці**: span'и батчуються до 5 с, на `close` provider робить shutdown.
190
212
  - Помилки експорту (`diag`) пишуться JSON-логером як `otel_diag`.
191
213
 
package/dist/module.d.cts CHANGED
@@ -56,6 +56,14 @@ interface OtelOptions {
56
56
  * @default []
57
57
  */
58
58
  ignoreUserAgents?: string[];
59
+ /**
60
+ * Трейси поза вибіркою семплера з `OTEL_TRACES_SAMPLER*` (`always_*`,
61
+ * `traceidratio`, з `parentbased_` чи без), чий server span тривав ≥ `slowMs`,
62
+ * теж експортуються — увесь запит з локальними дочірніми span'ами, як
63
+ * `OTEL_TRACES_SLOW_MS` у libwts3. Бекенд рятує свою частину сам. Перекривається env
64
+ * `OTEL_TRACES_SLOW_MS`. 0 — вимкнено.
65
+ */
66
+ slowMs?: number;
59
67
  }
60
68
  declare const _default: any;
61
69
 
package/dist/module.d.mts CHANGED
@@ -56,6 +56,14 @@ interface OtelOptions {
56
56
  * @default []
57
57
  */
58
58
  ignoreUserAgents?: string[];
59
+ /**
60
+ * Трейси поза вибіркою семплера з `OTEL_TRACES_SAMPLER*` (`always_*`,
61
+ * `traceidratio`, з `parentbased_` чи без), чий server span тривав ≥ `slowMs`,
62
+ * теж експортуються — увесь запит з локальними дочірніми span'ами, як
63
+ * `OTEL_TRACES_SLOW_MS` у libwts3. Бекенд рятує свою частину сам. Перекривається env
64
+ * `OTEL_TRACES_SLOW_MS`. 0 — вимкнено.
65
+ */
66
+ slowMs?: number;
59
67
  }
60
68
  declare const _default: any;
61
69
 
package/dist/module.d.ts CHANGED
@@ -56,6 +56,14 @@ interface OtelOptions {
56
56
  * @default []
57
57
  */
58
58
  ignoreUserAgents?: string[];
59
+ /**
60
+ * Трейси поза вибіркою семплера з `OTEL_TRACES_SAMPLER*` (`always_*`,
61
+ * `traceidratio`, з `parentbased_` чи без), чий server span тривав ≥ `slowMs`,
62
+ * теж експортуються — увесь запит з локальними дочірніми span'ами, як
63
+ * `OTEL_TRACES_SLOW_MS` у libwts3. Бекенд рятує свою частину сам. Перекривається env
64
+ * `OTEL_TRACES_SLOW_MS`. 0 — вимкнено.
65
+ */
66
+ slowMs?: number;
59
67
  }
60
68
  declare const _default: any;
61
69
 
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": "^3.0.0 || ^4.0.0"
6
6
  },
7
- "version": "1.5.0",
7
+ "version": "1.6.1",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -1,8 +1,10 @@
1
+ import { type Sampler } from '@opentelemetry/sdk-trace-base';
1
2
  /** Серіалізована форма `logger.otel` з nuxt.config (див. module.ts). */
2
3
  export interface OtelRuntimeConfig {
3
4
  ignorePaths?: string[];
4
5
  ignoreStaticAssets?: boolean;
5
6
  ignoreUserAgents?: string[];
7
+ slowMs?: number;
6
8
  }
7
9
  export interface OtelSettings {
8
10
  ignorePaths: string[];
@@ -13,3 +15,25 @@ export declare const TRACER_NAME = "hotelme-nuxt-logger";
13
15
  export declare function otelRuntimeConfig(): OtelRuntimeConfig;
14
16
  /** Регекспи компілюються один раз, на першому запиті. */
15
17
  export declare function otelSettings(): OtelSettings;
18
+ export interface OtelSampling {
19
+ /** Частка head-вибірки з OTEL_TRACES_SAMPLER*, 0–1 */
20
+ ratio: number;
21
+ /** Поріг порятунку повільних трейсів, мс */
22
+ slowMs: number;
23
+ }
24
+ /**
25
+ * Той самий семплер, що SDK зібрав би з env сам (`buildSamplerFromEnv` не
26
+ * експортується) — щоб обгорнути його ForceSampler'ом. undefined — семплер,
27
+ * якого тут немає: лишається на SDK.
28
+ */
29
+ export declare function envSampler(): Sampler | undefined;
30
+ /**
31
+ * Порятунок повільних: `OTEL_TRACES_SLOW_MS` (перекриває `logger.otel.slowMs`)
32
+ * > 0 і семплер з `OTEL_TRACES_SAMPLER*` вибирає менше 100%. Інакше undefined —
33
+ * семплер з env без порятунку (envSampler), як до 1.6.0.
34
+ *
35
+ * Семплер з порятунком завжди parent-based: `traceidratio` без префікса
36
+ * поводиться як `parentbased_traceidratio` — фільтри serverSpan.ts без цього
37
+ * не працюють.
38
+ */
39
+ export declare function otelSampling(): OtelSampling | undefined;
@@ -1,4 +1,6 @@
1
1
  import { useRuntimeConfig } from "#imports";
2
+ import { AlwaysOffSampler, AlwaysOnSampler, ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
3
+ import { logger } from "../../logger.js";
2
4
  export const TRACER_NAME = "hotelme-nuxt-logger";
3
5
  export function otelRuntimeConfig() {
4
6
  return useRuntimeConfig().logger?.otel ?? {};
@@ -15,3 +17,43 @@ export function otelSettings() {
15
17
  };
16
18
  return settings;
17
19
  }
20
+ function envNumber(name) {
21
+ const raw = process.env[name]?.trim();
22
+ if (!raw) return void 0;
23
+ const value = Number(raw);
24
+ if (Number.isFinite(value)) return value;
25
+ logger.warn("otel_config_invalid", { error: `${name}=${raw} is not a number, ignored` });
26
+ return void 0;
27
+ }
28
+ function envSamplerConfig() {
29
+ const sampler = process.env.OTEL_TRACES_SAMPLER?.trim().toLowerCase() || "parentbased_always_on";
30
+ const parentBased = sampler.startsWith("parentbased_");
31
+ switch (parentBased ? sampler.slice("parentbased_".length) : sampler) {
32
+ case "always_on":
33
+ return { ratio: 1, parentBased };
34
+ case "always_off":
35
+ return { ratio: 0, parentBased };
36
+ case "traceidratio": {
37
+ const ratio = envNumber("OTEL_TRACES_SAMPLER_ARG");
38
+ return { ratio: ratio !== void 0 && ratio >= 0 && ratio <= 1 ? ratio : 1, parentBased };
39
+ }
40
+ default:
41
+ return void 0;
42
+ }
43
+ }
44
+ export function envSampler() {
45
+ const config = envSamplerConfig();
46
+ if (!config) return void 0;
47
+ const root = config.ratio >= 1 ? new AlwaysOnSampler() : config.ratio <= 0 ? new AlwaysOffSampler() : new TraceIdRatioBasedSampler(config.ratio);
48
+ return config.parentBased ? new ParentBasedSampler({ root }) : root;
49
+ }
50
+ export function otelSampling() {
51
+ const slowMs = envNumber("OTEL_TRACES_SLOW_MS") ?? otelRuntimeConfig().slowMs ?? 0;
52
+ if (slowMs <= 0) return void 0;
53
+ const ratio = envSamplerConfig()?.ratio;
54
+ if (ratio === void 0) {
55
+ logger.warn("otel_config_invalid", { error: `OTEL_TRACES_SLOW_MS ignored: unsupported OTEL_TRACES_SAMPLER=${process.env.OTEL_TRACES_SAMPLER}` });
56
+ return void 0;
57
+ }
58
+ return ratio < 1 ? { ratio, slowMs } : void 0;
59
+ }
@@ -5,7 +5,13 @@
5
5
  * - OTEL_EXPORTER_OTLP_TRACES_ENDPOINT / OTEL_EXPORTER_OTLP_ENDPOINT
6
6
  * (insecure чи TLS — за схемою URL). Без них трейсинг не вмикається:
7
7
  * експортер SDK інакше мовчки слав би на localhost:4317.
8
- * - OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG (типово parentbased_always_on)
8
+ * - OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG (типово parentbased_always_on).
9
+ *
10
+ * OTEL_TRACES_SLOW_MS (config.ts → otelSampling) — порятунок трейсів поза
11
+ * вибіркою, чий root тривав довше порогу (див. sampling.ts). Тоді семплер —
12
+ * HeadSampler з тією ж часткою, що в OTEL_TRACES_SAMPLER_ARG.
13
+ *
14
+ * `?force_otel=1` у запиті — трейс вибраний завжди (ForceSampler, як у libwts3).
9
15
  *
10
16
  * Root trace id — з `X-Request-ID`, див. requestTraceId.ts.
11
17
  *
@@ -11,6 +11,8 @@ import { logger } from "../../logger.js";
11
11
  import { currentRequestEvent, requestIdOf } from "../requestContext.js";
12
12
  import { serverSpanContext } from "./serverSpan.js";
13
13
  import { requestIdGenerator } from "./requestTraceId.js";
14
+ import { envSampler, otelSampling } from "./config.js";
15
+ import { ForceSampler, HeadSampler, SlowTraceSpanProcessor } from "./sampling.js";
14
16
  const diagLogger = {
15
17
  error: (message, ...args) => logger.error("otel_diag", { error: message, args }),
16
18
  warn: (message, ...args) => logger.warn("otel_diag", { error: message, args }),
@@ -47,14 +49,18 @@ export default defineNitroPlugin((nitroApp) => {
47
49
  }
48
50
  diag.setLogger(diagLogger, DiagLogLevel.WARN);
49
51
  const serviceName = process.env.OTEL_SERVICE_NAME || String(useRuntimeConfig().public.projectName || "hotelme");
50
- const spanProcessor = new BatchSpanProcessor(new OTLPTraceExporter(), {
52
+ const batchProcessor = new BatchSpanProcessor(new OTLPTraceExporter(), {
51
53
  maxQueueSize: 2048,
52
54
  maxExportBatchSize: 512,
53
55
  scheduledDelayMillis: 5e3,
54
56
  exportTimeoutMillis: 1e4
55
57
  });
58
+ const sampling = otelSampling();
59
+ const spanProcessor = sampling ? new SlowTraceSpanProcessor(batchProcessor, sampling.slowMs) : batchProcessor;
60
+ const baseSampler = sampling ? new HeadSampler(sampling.ratio) : envSampler();
56
61
  const provider = new NodeTracerProvider({
57
62
  resource: resourceFromAttributes({ "service.name": serviceName }),
63
+ ...baseSampler && { sampler: new ForceSampler(baseSampler) },
58
64
  spanProcessors: [spanProcessor],
59
65
  idGenerator: requestIdGenerator
60
66
  });
@@ -77,5 +83,12 @@ export default defineNitroPlugin((nitroApp) => {
77
83
  context.disable();
78
84
  }
79
85
  });
80
- logger.info("otel_initialized", { service: serviceName, endpoint });
86
+ logger.info("otel_initialized", {
87
+ service: serviceName,
88
+ endpoint,
89
+ sampler: process.env.OTEL_TRACES_SAMPLER || "parentbased_always_on",
90
+ sampler_arg: process.env.OTEL_TRACES_SAMPLER_ARG,
91
+ slow_ms: sampling?.slowMs,
92
+ force_otel: !!baseSampler
93
+ });
81
94
  });
@@ -0,0 +1,64 @@
1
+ import { type Attributes, type Context, type Link, type SpanKind } from '@opentelemetry/api';
2
+ import { type ReadableSpan, type Sampler, type SamplingResult, type Span, type SpanProcessor } from '@opentelemetry/sdk-trace-base';
3
+ /** Контекст, у якому ForceSampler вибирає кожен span — його й усіх дочірніх. */
4
+ export declare function forceSampled(context: Context): Context;
5
+ /**
6
+ * `?force_otel=1` як у libwts3: запит вибраний попри частку семплера, фільтри
7
+ * й вхідний `traceparent …-00`. Позначка живе в контексті, тож дочірні span'и
8
+ * (внутрішні `/api/*`, вихідні fetch) теж вибрані, а бекенд отримує
9
+ * `traceparent …-01` і пише свою частину повністю. Решта — `base`.
10
+ */
11
+ export declare class ForceSampler implements Sampler {
12
+ private readonly base;
13
+ constructor(base: Sampler);
14
+ shouldSample(context: Context, traceId: string, spanName: string, spanKind: SpanKind, attributes: Attributes, links: Link[]): SamplingResult;
15
+ toString(): string;
16
+ }
17
+ /**
18
+ * `parentbased_traceidratio`, у якого решта трейсів — RECORD замість
19
+ * NOT_RECORD: span'и пишуться в пам'ять для SlowTraceSpanProcessor, але
20
+ * назовні йде `traceparent …-00`: бекенд вирішує сам (libwts3 теж рятує
21
+ * повільні).
22
+ *
23
+ * Батько вибраний → вибраний. Невибраний батько:
24
+ * - власний record-only span або вхідний `traceparent …-00` → RECORD;
25
+ * - unsampledContext із serverSpan.ts (статика, ignorePaths, ignoreUserAgents) —
26
+ * локальний non-recording span → NOT_RECORD, фільтри працюють як раніше.
27
+ */
28
+ export declare class HeadSampler implements Sampler {
29
+ private readonly ratio;
30
+ constructor(ratio: number);
31
+ shouldSample(context: Context, traceId: string): SamplingResult;
32
+ toString(): string;
33
+ }
34
+ /**
35
+ * Порятунок повільних трейсів, які не потрапили в head-вибірку — як
36
+ * OTEL_TRACES_SLOW_MS у libwts3: рішення на весь запит, а не на окремий span.
37
+ *
38
+ * Вибрані span'и йдуть у `next` одразу. Record-only (див. HeadSampler)
39
+ * буферизуються до кінця свого локального root'а — server span'а без батька
40
+ * або з віддаленим батьком. Root тривав ≥ `slowMs` → увесь запит іде в `next`
41
+ * з прапорцем sampled, root — з `sampling.kept_by: "slow"`; інакше буфер
42
+ * скидається. Бекенд отримав `traceparent …-00`, тож його span'и в трейсі є,
43
+ * лише якщо він сам їх врятував (libwts3: тривав ≥ свого OTEL_TRACES_SLOW_MS).
44
+ *
45
+ * Ключ буфера — span id локального root'а, не trace id: кілька запитів з одним
46
+ * trace id (один вхідний `traceparent`, повтор `X-Request-ID`) вирішуються
47
+ * кожен за своєю тривалістю.
48
+ *
49
+ * Рішення пам'ятається, щоб span'и, які закінчились після root'а
50
+ * (fire-and-forget fetch), пішли за ним, а не зависли в буфері.
51
+ */
52
+ export declare class SlowTraceSpanProcessor implements SpanProcessor {
53
+ private readonly next;
54
+ private readonly slowMs;
55
+ /** Record-only span → span id його локального root'а */
56
+ private readonly roots;
57
+ private readonly pending;
58
+ private readonly decisions;
59
+ constructor(next: SpanProcessor, slowMs: number);
60
+ onStart(span: Span, parentContext: Context): void;
61
+ onEnd(span: ReadableSpan): void;
62
+ forceFlush(): Promise<void>;
63
+ shutdown(): Promise<void>;
64
+ }
@@ -0,0 +1,126 @@
1
+ import { createContextKey, isSpanContextValid, trace, TraceFlags } from "@opentelemetry/api";
2
+ import { hrTimeToMilliseconds } from "@opentelemetry/core";
3
+ import {
4
+ SamplingDecision,
5
+ TraceIdRatioBasedSampler
6
+ } from "@opentelemetry/sdk-trace-base";
7
+ const SAMPLED = { decision: SamplingDecision.RECORD_AND_SAMPLED };
8
+ const RECORDED = { decision: SamplingDecision.RECORD };
9
+ const DROPPED = { decision: SamplingDecision.NOT_RECORD };
10
+ const FORCED = createContextKey("hotelme-nuxt-logger.force_otel");
11
+ export function forceSampled(context) {
12
+ return context.setValue(FORCED, true);
13
+ }
14
+ export class ForceSampler {
15
+ constructor(base) {
16
+ this.base = base;
17
+ }
18
+ shouldSample(context, traceId, spanName, spanKind, attributes, links) {
19
+ if (context.getValue(FORCED)) return SAMPLED;
20
+ return this.base.shouldSample(context, traceId, spanName, spanKind, attributes, links);
21
+ }
22
+ toString() {
23
+ return `ForceSampler{${this.base}}`;
24
+ }
25
+ }
26
+ export class HeadSampler {
27
+ ratio;
28
+ constructor(ratio) {
29
+ this.ratio = new TraceIdRatioBasedSampler(ratio);
30
+ }
31
+ shouldSample(context, traceId) {
32
+ const parentSpan = trace.getSpan(context);
33
+ const parent = parentSpan?.spanContext();
34
+ if (!parentSpan || !parent || !isSpanContextValid(parent)) {
35
+ return this.ratio.shouldSample(context, traceId).decision === SamplingDecision.RECORD_AND_SAMPLED ? SAMPLED : RECORDED;
36
+ }
37
+ if (parent.traceFlags & TraceFlags.SAMPLED) return SAMPLED;
38
+ return parent.isRemote || parentSpan.isRecording() ? RECORDED : DROPPED;
39
+ }
40
+ toString() {
41
+ return `HeadSampler{${this.ratio}}`;
42
+ }
43
+ }
44
+ const MAX_PENDING_TRACES = 2e3;
45
+ const MAX_SPANS_PER_TRACE = 512;
46
+ const MAX_DECISIONS = 2e3;
47
+ function rememberBounded(map, key, value, max) {
48
+ if (map.size >= max) map.delete(map.keys().next().value);
49
+ map.set(key, value);
50
+ }
51
+ function isLocalRoot(span) {
52
+ return !span.parentSpanContext || !!span.parentSpanContext.isRemote;
53
+ }
54
+ function asSampled(span, extraAttributes) {
55
+ const spanContext = { ...span.spanContext(), traceFlags: span.spanContext().traceFlags | TraceFlags.SAMPLED };
56
+ return {
57
+ name: span.name,
58
+ kind: span.kind,
59
+ spanContext: () => spanContext,
60
+ parentSpanContext: span.parentSpanContext,
61
+ startTime: span.startTime,
62
+ endTime: span.endTime,
63
+ status: span.status,
64
+ attributes: extraAttributes ? { ...span.attributes, ...extraAttributes } : span.attributes,
65
+ links: span.links,
66
+ events: span.events,
67
+ duration: span.duration,
68
+ ended: span.ended,
69
+ resource: span.resource,
70
+ instrumentationScope: span.instrumentationScope,
71
+ droppedAttributesCount: span.droppedAttributesCount,
72
+ droppedEventsCount: span.droppedEventsCount,
73
+ droppedLinksCount: span.droppedLinksCount
74
+ };
75
+ }
76
+ export class SlowTraceSpanProcessor {
77
+ constructor(next, slowMs) {
78
+ this.next = next;
79
+ this.slowMs = slowMs;
80
+ }
81
+ /** Record-only span → span id його локального root'а */
82
+ roots = /* @__PURE__ */ new WeakMap();
83
+ pending = /* @__PURE__ */ new Map();
84
+ decisions = /* @__PURE__ */ new Map();
85
+ onStart(span, parentContext) {
86
+ if (!(span.spanContext().traceFlags & TraceFlags.SAMPLED)) {
87
+ const parent = trace.getSpan(parentContext);
88
+ const root = !isLocalRoot(span) && parent ? this.roots.get(parent) : void 0;
89
+ this.roots.set(span, root ?? span.spanContext().spanId);
90
+ }
91
+ this.next.onStart(span, parentContext);
92
+ }
93
+ onEnd(span) {
94
+ if (span.spanContext().traceFlags & TraceFlags.SAMPLED) return this.next.onEnd(span);
95
+ const root = this.roots.get(span) ?? span.spanContext().spanId;
96
+ const decision = this.decisions.get(root);
97
+ if (decision !== void 0) {
98
+ if (decision) this.next.onEnd(asSampled(span));
99
+ return;
100
+ }
101
+ if (!isLocalRoot(span)) {
102
+ let spans2 = this.pending.get(root);
103
+ if (!spans2) {
104
+ spans2 = [];
105
+ rememberBounded(this.pending, root, spans2, MAX_PENDING_TRACES);
106
+ }
107
+ if (spans2.length < MAX_SPANS_PER_TRACE) spans2.push(span);
108
+ return;
109
+ }
110
+ const spans = this.pending.get(root) ?? [];
111
+ this.pending.delete(root);
112
+ const keep = hrTimeToMilliseconds(span.duration) >= this.slowMs;
113
+ rememberBounded(this.decisions, root, keep, MAX_DECISIONS);
114
+ if (!keep) return;
115
+ for (const child of spans) this.next.onEnd(asSampled(child));
116
+ this.next.onEnd(asSampled(span, { "sampling.kept_by": "slow" }));
117
+ }
118
+ forceFlush() {
119
+ return this.next.forceFlush();
120
+ }
121
+ shutdown() {
122
+ this.pending.clear();
123
+ this.decisions.clear();
124
+ return this.next.shutdown();
125
+ }
126
+ }
@@ -19,6 +19,11 @@ declare module 'h3' {
19
19
  * невибраний контекст, а не просто відсутність span'а: інакше їхні внутрішні
20
20
  * /api-виклики, вихідні fetch і span'и бекенда стали б осиротілими
21
21
  * root-трейсами. Дочірні запити успадковують його через parent-based семплер.
22
+ * Виняток — внутрішній підзапит з локальним span'ом у контексті: він
23
+ * лишається в контексті сторінки без власного span'а.
24
+ *
25
+ * `?force_otel=1` (як у libwts3) — трейс вибраний попри фільтри, частку
26
+ * семплера й вхідний `traceparent …-00`, root з `sampling.kept_by: "force"`.
22
27
  *
23
28
  * Кладе в event.context `otelSpan` (назва сторінки, `i18n.locale`), віддає
24
29
  * `X-Trace-Id` у відповіді.
@@ -3,6 +3,7 @@ import { context, isSpanContextValid, propagation, trace, SpanKind, SpanStatusCo
3
3
  import { isTracingSuppressed } from "@opentelemetry/core";
4
4
  import { requestIdOf } from "../requestContext.js";
5
5
  import { otelSettings, TRACER_NAME } from "./config.js";
6
+ import { forceSampled } from "./sampling.js";
6
7
  import { requestIdGenerator } from "./requestTraceId.js";
7
8
  const STATIC_ASSET = /\.(css|m?js|svg|png|jpe?g|gif|webp|avif|ico|woff2?|ttf|map|json|txt|xml)$/i;
8
9
  function unsampledContext(active) {
@@ -22,15 +23,20 @@ export function serverSpanContext(event) {
22
23
  const settings = otelSettings();
23
24
  const userAgent = getHeader(event, "user-agent") ?? "";
24
25
  const [pathname = "", query = ""] = event.path.split("?");
25
- if (isTracingSuppressed(active) || settings.ignoreUserAgent?.test(userAgent) || settings.ignorePaths.some((prefix) => pathname.startsWith(prefix)) || settings.ignoreStaticAssets && STATIC_ASSET.test(pathname)) {
26
- return isTracingSuppressed(active) ? active : unsampledContext(active);
26
+ if (isTracingSuppressed(active)) return active;
27
+ const forced = new URLSearchParams(query).get("force_otel") === "1";
28
+ if (!forced && (settings.ignoreUserAgent?.test(userAgent) || settings.ignorePaths.some((prefix) => pathname.startsWith(prefix)) || settings.ignoreStaticAssets && STATIC_ASSET.test(pathname))) {
29
+ const parent = trace.getSpan(active)?.spanContext();
30
+ if (parent && !parent.isRemote && isSpanContextValid(parent)) return active;
31
+ return unsampledContext(active);
27
32
  }
28
33
  const carrier = {};
29
34
  const traceparent = getHeader(event, "traceparent");
30
35
  const tracestate = getHeader(event, "tracestate");
31
36
  if (traceparent) carrier.traceparent = traceparent;
32
37
  if (tracestate) carrier.tracestate = tracestate;
33
- const parentCtx = propagation.extract(active, carrier);
38
+ const extracted = propagation.extract(active, carrier);
39
+ const parentCtx = forced ? forceSampled(extracted) : extracted;
34
40
  const span = trace.getTracer(TRACER_NAME).startSpan(
35
41
  `${event.method} ${pathname.startsWith("/api/") ? "/api/*" : "/*"}`,
36
42
  {
@@ -40,7 +46,8 @@ export function serverSpanContext(event) {
40
46
  "url.path": pathname,
41
47
  "url.query": query,
42
48
  "server.address": getHeader(event, "host") ?? "",
43
- "user_agent.original": userAgent
49
+ "user_agent.original": userAgent,
50
+ ...forced && { "sampling.kept_by": "force" }
44
51
  }
45
52
  },
46
53
  parentCtx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hotelme-nuxt-logger",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
4
4
  "description": "Стандартний logger для Nuxt проектів з JSON виводом сумісним з бекенд-логами",
5
5
  "keywords": [
6
6
  "nuxt",