├── .prettierignore ├── .npmrc ├── examples ├── cloudflare-workers │ ├── .gitignore │ ├── package.json │ ├── wrangler.toml │ └── worker.ts ├── bun │ ├── bun.lockb │ ├── tsconfig.json │ ├── package.json │ └── server.ts ├── node │ ├── package.json │ └── server.ts └── basic.ts ├── pnpm-workspace.yaml ├── bench ├── bun.lockb ├── package.json └── index.ts ├── src ├── exporter.console.d.ts ├── exporter.otel.http.d.ts ├── exporter.zipkin.d.ts ├── utils.ts ├── utils.d.ts ├── _internal │ └── index.ts ├── async.d.ts ├── exporter.zipkin.ts ├── index.ts ├── async.ts ├── measure.test.ts ├── exporter.otel.http.ts ├── exporter.console.ts ├── async.test.ts ├── index.d.ts └── rian.test.ts ├── .editorconfig ├── global.d.ts ├── .gitignore ├── bundt.config.ts ├── tsconfig.json ├── .github └── workflows │ └── ci.yml ├── license ├── files ├── logo-dark.svg └── logo-light.svg ├── package.json ├── readme.md └── pnpm-lock.yaml /.prettierignore: -------------------------------------------------------------------------------- 1 | .gitignore -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | enable-pre-post-scripts=true -------------------------------------------------------------------------------- /examples/cloudflare-workers/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - esbuild 3 | -------------------------------------------------------------------------------- /bench/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maraisr/rian/HEAD/bench/bun.lockb -------------------------------------------------------------------------------- /examples/bun/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maraisr/rian/HEAD/examples/bun/bun.lockb -------------------------------------------------------------------------------- /examples/bun/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["bun-types"] 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/exporter.console.d.ts: -------------------------------------------------------------------------------- 1 | import type { Exporter } from 'rian'; 2 | 3 | export const exporter: (max_columns?: number) => Exporter; 4 | -------------------------------------------------------------------------------- /src/exporter.otel.http.d.ts: -------------------------------------------------------------------------------- 1 | import type { Exporter } from 'rian'; 2 | 3 | export const exporter: (request: (payload: any) => any) => Exporter; 4 | -------------------------------------------------------------------------------- /src/exporter.zipkin.d.ts: -------------------------------------------------------------------------------- 1 | import type { Exporter } from 'rian'; 2 | 3 | export const exporter: (request: (payload: any) => any) => Exporter; 4 | -------------------------------------------------------------------------------- /examples/bun/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "bun-types": "1.0.14" 4 | }, 5 | "dependencies": { 6 | "rian": "^0.3.7" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "tsm server.ts" 4 | }, 5 | "dependencies": { 6 | "rian": "*" 7 | }, 8 | "devDependencies": { 9 | "@types/node": "^18.15.3", 10 | "tsm": "^2.2.1" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 4 6 | indent_style = tab 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.{json,yml,yaml,md}] 13 | indent_style = space 14 | -------------------------------------------------------------------------------- /bench/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "dependencies": { 4 | "@opentelemetry/api": "1.4.1", 5 | "@opentelemetry/sdk-trace-base": "1.10.0", 6 | "@opentelemetry/sdk-trace-node": "1.10.0", 7 | "@types/benchmark": "2.1.2", 8 | "benchmark": "2.1.4" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /examples/cloudflare-workers/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "wrangler dev --local" 4 | }, 5 | "dependencies": { 6 | "rian": "next" 7 | }, 8 | "devDependencies": { 9 | "@cloudflare/workers-types": "^3.2.0", 10 | "wrangler": "^2.12.3" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | declare const RIAN_VERSION: string; 2 | 3 | declare module 'node:async_hooks' { 4 | export class AsyncLocalStorage { 5 | public run( 6 | store: T, 7 | fn: (...args: any[]) => R, 8 | ...args: Parameters 9 | ): R; 10 | public getStore(): T; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/cloudflare-workers/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "rian-example" 2 | 3 | main = "worker.ts" 4 | workers_dev = true 5 | 6 | compatibility_date = "2023-03-07" 7 | compatibility_flags = ['nodejs_compat'] 8 | 9 | kv_namespaces = [ 10 | { binding = "DATA", id = "", preview_id = "" } 11 | ] 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | *-lock.json 4 | *.lock 5 | *.log 6 | 7 | /coverage 8 | /.nyc_output 9 | 10 | # Editors 11 | *.iml 12 | /.idea 13 | /.vscode 14 | 15 | # Dist 16 | /*.js 17 | /*.mjs 18 | /*.d.ts 19 | !/global.d.ts 20 | 21 | /exporter* 22 | /utils/* 23 | 24 | /sample 25 | /examples/**/*-lock.yaml 26 | -------------------------------------------------------------------------------- /bundt.config.ts: -------------------------------------------------------------------------------- 1 | import { define } from 'bundt/config'; 2 | import { version } from './package.json'; 3 | 4 | export default define((input, options) => { 5 | options.minifySyntax = true; 6 | 7 | options.define = { 8 | RIAN_VERSION: `"${version}"`, 9 | }; 10 | 11 | options.external?.push('node:async_hooks'); 12 | 13 | return options; 14 | }); 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@marais/tsconfig", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "moduleResolution": "bundler", 6 | "paths": { 7 | "rian": ["src/index.d.ts"], 8 | "rian/*": ["src/*.d.ts"] 9 | } 10 | }, 11 | "include": ["global.d.ts", "src/*.d.ts", "src", "test"], 12 | "exclude": ["node_modules"] 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: {} 7 | 8 | jobs: 9 | test: 10 | name: Node.js v${{ matrix.node }} 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node: [20, 22, 24] 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: (env) setup pnpm 19 | uses: pnpm/action-setup@v4 20 | with: 21 | version: 10.13.1 22 | 23 | - name: (env) setup node v${{ matrix.node }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node }} 27 | cache: pnpm 28 | 29 | - run: pnpm install --ignore-scripts 30 | - run: pnpm run build 31 | - run: pnpm run test 32 | - run: pnpm run typecheck 33 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { SpanBuilder } from 'rian'; 2 | 3 | export function measure any>( 4 | span: SpanBuilder, 5 | fn: Fn, 6 | ): ReturnType { 7 | try { 8 | var r = fn(span), 9 | is_promise = r instanceof Promise; 10 | 11 | if (is_promise) { 12 | // @ts-expect-error 13 | span.__add_promise( 14 | r 15 | .catch( 16 | (e: Error): void => 17 | void span.set_context({ 18 | error: e, 19 | }), 20 | ) 21 | .finally(() => span.end()), 22 | ); 23 | } 24 | 25 | return r; 26 | } catch (e) { 27 | if (e instanceof Error) 28 | span.set_context({ 29 | error: e, 30 | }); 31 | throw e; 32 | } finally { 33 | // @ts-expect-error TS2454 34 | if (is_promise !== true) span.end(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/utils.d.ts: -------------------------------------------------------------------------------- 1 | import type { SpanBuilder } from 'rian'; 2 | 3 | /** 4 | * With a passed function, `measure` will run the function and once finishes, will end the span. 5 | * 6 | * The measure method will return whatever the function is, so if it's a promise, it returns a 7 | * promise and so on. Any error is caught and re thrown, and automatically tracked in the 8 | * context under the `error` property. 9 | * 10 | * All promises are tracked, and awaited on a `report`. 11 | * 12 | * This is a utility method, but is functionally equivalent to `scope.span('name')(fn)`. 13 | * 14 | * @example 15 | * 16 | * ```text 17 | * const data = await measure(scope, get_data); 18 | * // or with arguments: 19 | * const data = await measure(scope, () => get_data('foo', 'bar')); 20 | * ``` 21 | */ 22 | export function measure any>( 23 | span: SpanBuilder, 24 | fn: Fn, 25 | ): ReturnType; 26 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Marais Rossouw 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /examples/basic.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import * as Rian from '../async.mjs'; 4 | import { exporter } from '../exporter.console.mjs'; 5 | 6 | Rian.configure('basic', { 7 | 'service.version': 'DEV', 8 | }); 9 | 10 | await Rian.tracer('basic')(async () => { 11 | await Promise.all([ 12 | Rian.span('setup')(() => sleep(93)), 13 | Rian.span('bootstrap')(() => sleep(41)), 14 | Rian.span('building')(() => sleep(31)), 15 | ]); 16 | 17 | await Promise.all([ 18 | Rian.span('precompile')(() => sleep(59)), 19 | sleep(23).then(() => Rian.span('verify')(() => sleep(79))), 20 | ]); 21 | 22 | await Rian.span('spawn thread')(() => 23 | Rian.tracer('thread #1')(async () => 24 | Promise.all([ 25 | Rian.span('setup')(() => sleep(20)), 26 | Rian.span('bootstrap')(() => sleep(63)), 27 | ]), 28 | ), 29 | ); 30 | 31 | Rian.span('doesnt finish'); 32 | 33 | await Rian.span('running')(() => { 34 | return Rian.span('e2e')(async () => { 35 | await sleep(301); 36 | return Rian.span('snapshot')(() => sleep(36)); 37 | }); 38 | }); 39 | 40 | await sleep(10); 41 | 42 | await Rian.span( 43 | 'url for page /my-product/sleeping-bags-and-tents failed to find a selector', 44 | )(() => sleep(11)); 45 | }); 46 | 47 | Rian.report(exporter(process.stdout.columns)); 48 | 49 | // -- 50 | 51 | function sleep(ms: number) { 52 | return new Promise((resolve) => setTimeout(resolve, ms)); 53 | } 54 | -------------------------------------------------------------------------------- /src/_internal/index.ts: -------------------------------------------------------------------------------- 1 | // 🚨 WARNING THIS FILE WILL DUPLICATE ITSELF WITH EACH ENTRYPOINT 2 | 3 | import type { Resource, Context, Exporter, ScopedSpans, Span } from 'rian'; 4 | 5 | // --- 6 | 7 | let resource = {} as Resource; 8 | 9 | export function configure(name: string, attributes: Context = {}) { 10 | resource = { 11 | ...attributes, 12 | ['service.name']: name, 13 | ['telemetry.sdk.name']: 'rian', 14 | ['telemetry.sdk.version']: RIAN_VERSION, 15 | }; 16 | } 17 | 18 | // --- 19 | 20 | export const span_buffer = new Set<[Span, { name: string }]>(); 21 | export const wait_promises = new WeakMap<{ name: string }, Set>>(); 22 | 23 | export async function report(exporter: Exporter) { 24 | const ps = []; 25 | const scopes = new Map<{ name: string }, ScopedSpans>(); 26 | const scopeSpans: Array = []; 27 | 28 | for (let [span, scope] of span_buffer) { 29 | let scope_spans = scopes.get(scope); 30 | 31 | if (scope_spans == null) { 32 | scope_spans = { scope, spans: [] }; 33 | scopeSpans.push(scope_spans); 34 | scopes.set(scope, scope_spans); 35 | 36 | // If we are in here, we have not seen this scope yet, so also enque all of its wait_promises 37 | if (wait_promises.has(scope)) { 38 | const pss = wait_promises.get(scope)!; 39 | ps.push(...pss); 40 | pss.clear(); 41 | } 42 | } 43 | 44 | (scope_spans.spans as Span[]).push(span); 45 | } 46 | 47 | if (ps.length) await Promise.all(ps); 48 | 49 | span_buffer.clear(); 50 | 51 | return exporter({ resource, scopeSpans }); 52 | } 53 | -------------------------------------------------------------------------------- /examples/cloudflare-workers/worker.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { currentSpan, report, span, tracer } from 'rian/async'; 4 | 5 | const consoleExporter = (scopes) => { 6 | console.log(Array.from(scopes.scopeSpans).flatMap((scope) => scope.spans)); 7 | }; 8 | 9 | const trace = tracer('rian-example-cloudflare-workers'); 10 | 11 | const indexHandler = async (data: KVNamespace) => { 12 | const payload = await span('get_data')(() => { 13 | currentSpan().set_context({ 14 | 'kv.key': 'example', 15 | type: 'json', 16 | }); 17 | 18 | return data.get('example', { 19 | type: 'json', 20 | }); 21 | }); 22 | 23 | return new Response(JSON.stringify(payload), { 24 | status: 200, 25 | headers: { 26 | 'content-type': 'application/json', 27 | 'cache-control': 'public,max-age=10', 28 | }, 29 | }); 30 | }; 31 | 32 | const fetchHandler: ExportedHandlerFetchHandler<{ 33 | DATA: KVNamespace; 34 | }> = async (req, env, ctx) => { 35 | const url = new URL(req.url); 36 | 37 | // ~> There may be an incoming traceparent. 38 | const traceparent = req.headers.get('traceparent'); 39 | 40 | // ~> Create the tracer for this request 41 | return trace(async () => { 42 | const response = span( 43 | `${req.method} :: ${url.pathname}`, 44 | traceparent, 45 | )(async () => { 46 | if (url.pathname === '/') return indexHandler(env.DATA); 47 | }); 48 | 49 | ctx.waitUntil(report(consoleExporter)); 50 | 51 | return ( 52 | response || 53 | new Response('not found', { 54 | status: 404, 55 | }) 56 | ); 57 | }); 58 | }; 59 | 60 | export default { 61 | fetch: fetchHandler, 62 | }; 63 | -------------------------------------------------------------------------------- /src/async.d.ts: -------------------------------------------------------------------------------- 1 | import type { CallableSpanBuilder, Options, SpanBuilder } from 'rian'; 2 | import type { Traceparent } from 'tctx/traceparent'; 3 | 4 | export { report, configure } from 'rian'; 5 | 6 | /** 7 | * Returns the current span in the current execution context. 8 | * 9 | * This will throw an error if there is no current span. 10 | * 11 | * @example 12 | * 13 | * ```ts 14 | * function doWork() { 15 | * const span = currentSpan(); 16 | * span.set_context({ foo: 'bar' }); 17 | * } 18 | * 19 | * span('some-name')(() => { 20 | * doWork(); // will guarantee `currentSpan` returns this span 21 | * }); 22 | * ``` 23 | */ 24 | export function currentSpan(): SpanBuilder; 25 | 26 | /** 27 | * Creates a new span for the currently active tracer. 28 | * 29 | * @example 30 | * 31 | * ```ts 32 | * tracer('some-name')(() => { 33 | * // some deeply nested moments later 34 | * const s = span('my-span'); 35 | * }); 36 | * ``` 37 | */ 38 | export function span( 39 | name: string, 40 | parent_id?: Traceparent | string, 41 | ): CallableSpanBuilder; 42 | 43 | export type Tracer = (cb: T) => ReturnType; 44 | 45 | /** 46 | * A tracer is a logical unit in your application. This alleviates the need to pass around a tracer instance. 47 | * 48 | * All spans produced by a tracer will all collect into a single span collection that is given to {@link report}. 49 | * 50 | * @example 51 | * 52 | * ```ts 53 | * const trace = tracer('server'); 54 | * 55 | * trace(() => { 56 | * // application logic 57 | * }); 58 | * ``` 59 | */ 60 | export function tracer any>( 61 | name: string, 62 | options?: Options, 63 | ): Tracer; 64 | -------------------------------------------------------------------------------- /examples/bun/server.ts: -------------------------------------------------------------------------------- 1 | import * as Rian from 'rian/async'; 2 | import { exporter } from 'rian/exporter.otel.http'; 3 | 4 | Rian.configure('bun-api', { 5 | 'bun.version': Bun.version, 6 | }); 7 | 8 | async function get_data() { 9 | return Rian.span('get_data')(async () => { 10 | const users = await Rian.span('SELECT * FROM users')(async (s) => { 11 | s.set_context({ 12 | 'db.system': 'mysql', 13 | }); 14 | 15 | await new Promise((resolve) => setTimeout(resolve, 100)); 16 | 17 | return [{ user: 'test' }]; 18 | }); 19 | 20 | Rian.currentSpan().set_context({ 21 | 'users.count': users.length, 22 | }); 23 | 24 | return users; 25 | }); 26 | } 27 | 28 | const tracer = Rian.tracer('bun-api'); 29 | 30 | setInterval(() => { 31 | Rian.report( 32 | exporter((p) => 33 | fetch('http://127.0.0.1:3000', { 34 | method: 'POST', 35 | body: JSON.stringify(p), 36 | }), 37 | ), 38 | ); 39 | }, 1e3); 40 | 41 | Bun.serve({ 42 | port: 8080, 43 | async fetch(req: Request) { 44 | const u = new URL(req.url); 45 | 46 | return tracer(async () => 47 | Rian.span(`${req.method} ${u.pathname}`)(async (s) => { 48 | s.set_context({ 49 | 'http.method': req.method, 50 | 'http.pathname': u.pathname, 51 | 'http.scheme': u.protocol.replace(':', ''), 52 | 'http.host': u.host, 53 | 'http.port': u.port, 54 | 'bun.development': this.development, 55 | }); 56 | 57 | // Routing 58 | // ----------------------------------------- 59 | 60 | if (u.pathname == '/users') { 61 | const users = await get_data(); 62 | return new Response(JSON.stringify(users)); 63 | } 64 | 65 | return new Response('', { status: 404 }); 66 | }), 67 | ); 68 | }, 69 | }); 70 | -------------------------------------------------------------------------------- /src/exporter.zipkin.ts: -------------------------------------------------------------------------------- 1 | import { flattie } from 'flattie'; 2 | import type * as rian from 'rian'; 3 | 4 | interface Span { 5 | id: string; 6 | traceId: string; 7 | parentId: string | undefined; 8 | 9 | name?: string; 10 | kind?: string; 11 | 12 | timestamp: number; 13 | duration: number | undefined; 14 | 15 | tags: Record | undefined; 16 | 17 | localEndpoint?: { 18 | serviceName: string; 19 | }; 20 | 21 | annotations?: { value: string; timestamp: number }[]; 22 | } 23 | 24 | export const exporter = 25 | (request: (payload: any) => any): rian.Exporter => 26 | (trace) => { 27 | const zipkin: Span[] = []; 28 | 29 | for (let scope of trace.scopeSpans) { 30 | for (let span of scope.spans) { 31 | const { kind, error, ...span_ctx } = span.context; 32 | 33 | if (error) { 34 | if ('message' in error) { 35 | span_ctx.error = { 36 | name: error.name, 37 | message: error.message, 38 | stack: error.stack, 39 | }; 40 | } else { 41 | span_ctx.error = true; 42 | } 43 | } 44 | 45 | zipkin.push({ 46 | id: span.id.parent_id, 47 | traceId: span.id.trace_id, 48 | parentId: span.parent ? span.parent.parent_id : undefined, 49 | 50 | name: span.name, 51 | 52 | kind: kind === 'INTERNAL' ? undefined : kind, 53 | 54 | timestamp: span.start * 1000, 55 | 56 | duration: span.end 57 | ? (span.end - span.start) * 1000 58 | : undefined, 59 | 60 | localEndpoint: { 61 | serviceName: `${trace.resource['service.name']}@${scope.scope.name}`, 62 | }, 63 | 64 | tags: flattie( 65 | { 66 | ...trace.resource, 67 | ...span_ctx, 68 | }, 69 | '.', 70 | true, 71 | ), 72 | 73 | annotations: span.events.map((i) => ({ 74 | value: `${i.name} :: ${JSON.stringify(i.attributes)}`, 75 | timestamp: i.timestamp * 1000, 76 | })), 77 | }); 78 | } 79 | } 80 | 81 | return request(zipkin); 82 | }; 83 | -------------------------------------------------------------------------------- /examples/node/server.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { createServer, IncomingMessage, ServerResponse } from 'node:http'; 4 | import { configure, currentSpan, report, span, tracer } from 'rian/async'; 5 | import { exporter } from 'rian/exporter.otel.http'; 6 | 7 | configure('my-api', { 8 | 'deployment.environment': 'development', 9 | }); 10 | 11 | // ~> Create the tracer for this server 12 | const trace = tracer('rian-example-node', { 13 | sampler: () => true, // lets always sample 14 | }); 15 | 16 | const otel_exporter = exporter((payload) => 17 | // local jaeger instance 18 | fetch('http://localhost:4318/v1/traces', { 19 | method: 'POST', 20 | body: JSON.stringify(payload), 21 | headers: { 22 | 'Content-Type': 'application/json', 23 | }, 24 | }), 25 | ); 26 | 27 | async function get_data(name: string) { 28 | return span('get_data')( 29 | () => 30 | new Promise((resolve) => { 31 | currentSpan().add_event('got data', { 32 | name, 33 | name_length: name.length, 34 | }); 35 | 36 | setTimeout(resolve, Math.random() * 1000, { name }); 37 | }), 38 | ); 39 | } 40 | 41 | async function indexHandler(req: IncomingMessage, res: ServerResponse) { 42 | const data = await get_data('rian'); 43 | res.writeHead(200, { 44 | 'content-type': 'application/json', 45 | }); 46 | res.write(JSON.stringify(data)); 47 | res.end(); 48 | } 49 | 50 | const server = createServer((req, res) => { 51 | // ~> There may be an incoming traceparent. 52 | const traceparent = req.headers['traceparent'] as string; 53 | 54 | const url = new URL(req.url!, `http://${req.headers.host}`); 55 | 56 | span( 57 | `${req.method} ${url.pathname}`, 58 | traceparent, 59 | )(() => { 60 | if (req.url === '/') { 61 | span('indexHandler')(() => indexHandler(req, res)); 62 | } else { 63 | res.writeHead(404, { 64 | 'content-type': 'application/json', 65 | }); 66 | res.write('not found'); 67 | res.end(); 68 | } 69 | }); 70 | 71 | report(otel_exporter); 72 | }); 73 | 74 | // ~> Lets listen 75 | trace(() => { 76 | server.listen(8080); 77 | }); 78 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { CallableSpanBuilder, Options, Span, Tracer } from 'rian'; 2 | import { measure } from 'rian/utils'; 3 | import { span_buffer, wait_promises } from './_internal'; 4 | 5 | import { type Traceparent } from 'tctx/traceparent'; 6 | import * as traceparent from 'tctx/traceparent'; 7 | 8 | export { report, configure } from './_internal'; 9 | 10 | export function tracer(name: string, options?: Options): Tracer { 11 | const should_sample = options?.sampler ?? true; 12 | const clock = options?.clock ?? Date; 13 | 14 | const scope = { name }; 15 | 16 | const ps: Set> = new Set(); 17 | wait_promises.set(scope, ps); 18 | 19 | const span = ( 20 | name: string, 21 | parent_id?: Traceparent | string, 22 | ): CallableSpanBuilder => { 23 | // --- 24 | const parent = 25 | typeof parent_id === 'string' 26 | ? traceparent.parse(parent_id) || undefined 27 | : parent_id; 28 | const id = parent?.child() || traceparent.make(); 29 | 30 | const is_sampling = 31 | typeof should_sample == 'boolean' 32 | ? should_sample 33 | : should_sample(id.parent_id, parent, name, scope); 34 | if (is_sampling) traceparent.sample(id); 35 | else traceparent.unsample(id); 36 | 37 | // prettier-ignore 38 | const span_obj: Span = { 39 | id, parent, name, 40 | start: clock.now(), 41 | events: [], 42 | context: {}, 43 | }; 44 | 45 | is_sampling && span_buffer.add([span_obj, scope]); 46 | // --- 47 | 48 | const $: CallableSpanBuilder = (cb: any) => measure($, cb); 49 | 50 | $.traceparent = id; 51 | $.span = (name, p_id) => span(name, p_id || id); 52 | $.set_context = (ctx) => { 53 | if (typeof ctx === 'function') 54 | return void (span_obj.context = ctx(span_obj.context)); 55 | return void Object.assign(span_obj.context, ctx); 56 | }; 57 | $.add_event = (name, attributes) => { 58 | span_obj.events.push({ 59 | name, 60 | timestamp: clock.now(), 61 | attributes: attributes || {}, 62 | }); 63 | }; 64 | $.end = () => { 65 | if (span_obj.end == null) span_obj.end = clock.now(); 66 | }; 67 | 68 | // @ts-expect-error 69 | $.__add_promise = (p) => { 70 | ps.add(p); 71 | p.then(() => ps.delete(p)); 72 | }; 73 | 74 | return $; 75 | }; 76 | 77 | return { 78 | span, 79 | }; 80 | } 81 | -------------------------------------------------------------------------------- /files/logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /files/logo-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rian", 3 | "version": "0.4.0", 4 | "description": "Effective tracing for the edge and origins", 5 | "keywords": [ 6 | "opentelemetry", 7 | "trace-context", 8 | "opentracing", 9 | "traceparent", 10 | "measure", 11 | "monitor", 12 | "observe", 13 | "tracing", 14 | "zipkin", 15 | "tracer", 16 | "trace" 17 | ], 18 | "repository": "maraisr/rian", 19 | "license": "MIT", 20 | "author": "Marais Rossouw (https://marais.io)", 21 | "sideEffects": false, 22 | "exports": { 23 | ".": { 24 | "types": "./index.d.ts", 25 | "import": "./index.mjs", 26 | "require": "./index.js" 27 | }, 28 | "./async": { 29 | "types": "./async.d.ts", 30 | "import": "./async.mjs", 31 | "require": "./async.js" 32 | }, 33 | "./exporter.otel.http": { 34 | "types": "./exporter.otel.http.d.ts", 35 | "import": "./exporter.otel.http.mjs", 36 | "require": "./exporter.otel.http.js" 37 | }, 38 | "./exporter.zipkin": { 39 | "types": "./exporter.zipkin.d.ts", 40 | "import": "./exporter.zipkin.mjs", 41 | "require": "./exporter.zipkin.js" 42 | }, 43 | "./exporter.console": { 44 | "types": "./exporter.console.d.ts", 45 | "import": "./exporter.console.mjs", 46 | "require": "./exporter.console.js" 47 | }, 48 | "./utils": { 49 | "types": "./utils.d.ts", 50 | "import": "./utils.mjs", 51 | "require": "./utils.js" 52 | }, 53 | "./package.json": "./package.json" 54 | }, 55 | "main": "./index.js", 56 | "module": "./index.mjs", 57 | "types": "index.d.ts", 58 | "files": [ 59 | "*.mjs", 60 | "*.js", 61 | "*.d.ts", 62 | "!global.d.ts", 63 | "exporter.*/*", 64 | "utils/*" 65 | ], 66 | "scripts": { 67 | "bench": "node -r tsm bench/index.ts", 68 | "build": "bundt", 69 | "format": "prettier --write \"{*,{src,test}/**/*,examples/*/**,bench/*,.github/**/*}.+(ts|js|json|yml|md)\"", 70 | "test": "uvu src \".test.ts$\" -r tsm", 71 | "typecheck": "tsc --noEmit" 72 | }, 73 | "prettier": "@marais/prettier", 74 | "dependencies": { 75 | "flattie": "^1.1.1", 76 | "tctx": "^0.2.5" 77 | }, 78 | "devDependencies": { 79 | "@marais/prettier": "0.0.4", 80 | "@marais/tsconfig": "0.0.4", 81 | "@types/node": "24.0.15", 82 | "bundt": "2.0.0-next.5", 83 | "nanospy": "1.0.0", 84 | "prettier": "3.6.2", 85 | "tsm": "2.3.0", 86 | "typescript": "5.8.3", 87 | "uvu": "0.5.4" 88 | }, 89 | "volta": { 90 | "node": "24.4.1" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/async.ts: -------------------------------------------------------------------------------- 1 | import * as async_hooks from 'node:async_hooks'; 2 | 3 | import type { 4 | CallableSpanBuilder, 5 | ClockLike, 6 | Options, 7 | Sampler, 8 | SpanBuilder, 9 | Span, 10 | } from 'rian'; 11 | import type { Tracer } from 'rian/async'; 12 | import { measure } from 'rian/utils'; 13 | 14 | import { type Traceparent } from 'tctx/traceparent'; 15 | import * as traceparent from 'tctx/traceparent'; 16 | 17 | import { span_buffer, wait_promises } from './_internal'; 18 | 19 | export { configure, report } from './_internal'; 20 | 21 | type API = { 22 | sampler: Sampler | boolean; 23 | scope: { name: string }; 24 | clock: ClockLike; 25 | }; 26 | 27 | const resourceStore = new async_hooks.AsyncLocalStorage< 28 | [API, SpanBuilder | null] | null 29 | >(); 30 | 31 | export function currentSpan() { 32 | const scope = resourceStore.getStore()?.[1]; 33 | if (scope == null) throw new Error('no current span'); 34 | return scope; 35 | } 36 | 37 | export function span(name: string, parent_id?: Traceparent | string) { 38 | const context = resourceStore.getStore(); 39 | if (!context) throw Error('no current tracer'); 40 | 41 | const api = context[0]; 42 | const scope = api.scope; 43 | const current_span = context[1]; 44 | const should_sample = api.sampler; 45 | 46 | // --- 47 | const parent = 48 | typeof parent_id === 'string' 49 | ? traceparent.parse(parent_id) || undefined 50 | : parent_id || current_span?.traceparent; 51 | const id = parent?.child() || traceparent.make(); 52 | 53 | const is_sampling = 54 | typeof should_sample == 'boolean' 55 | ? should_sample 56 | : should_sample(id.parent_id, parent, name, scope); 57 | if (is_sampling) traceparent.sample(id); 58 | else traceparent.unsample(id); 59 | 60 | // prettier-ignore 61 | const span_obj: Span = { 62 | id, parent, name, 63 | start: api.clock.now(), 64 | events: [], 65 | context: {}, 66 | }; 67 | 68 | is_sampling && span_buffer.add([span_obj, scope]); 69 | // --- 70 | 71 | const $: CallableSpanBuilder = (cb: any) => 72 | resourceStore.run([api, $], measure, $, cb); 73 | 74 | $.traceparent = id; 75 | $.span = (name: string) => resourceStore.run([api, $], span, name); 76 | $.set_context = (ctx) => { 77 | if (typeof ctx === 'function') 78 | return void (span_obj.context = ctx(span_obj.context)); 79 | Object.assign(span_obj.context, ctx); 80 | }; 81 | $.add_event = (name, attributes) => { 82 | span_obj.events.push({ 83 | name, 84 | timestamp: api.clock.now(), 85 | attributes: attributes || {}, 86 | }); 87 | }; 88 | $.end = () => { 89 | if (span_obj.end == null) span_obj.end = api.clock.now(); 90 | }; 91 | 92 | const ps = wait_promises.get(scope)!; 93 | // @ts-expect-error 94 | $.__add_promise = (p) => { 95 | ps.add(p); 96 | p.then(() => ps.delete(p)); 97 | }; 98 | 99 | return $; 100 | } 101 | 102 | export function tracer any>( 103 | name: string, 104 | options?: Options, 105 | ): Tracer { 106 | const sampler = options?.sampler ?? true; 107 | 108 | const scope = { name }; 109 | 110 | const api: API = { 111 | scope, 112 | sampler, 113 | clock: options?.clock ?? Date, 114 | }; 115 | 116 | wait_promises.set(scope, new Set()); 117 | 118 | return function (cb) { 119 | const parent = resourceStore.getStore(); 120 | return resourceStore.run([api, parent?.[1] || null], cb); 121 | }; 122 | } 123 | -------------------------------------------------------------------------------- /src/measure.test.ts: -------------------------------------------------------------------------------- 1 | import { spy } from 'nanospy'; 2 | import { suite, test } from 'uvu'; 3 | import * as assert from 'uvu/assert'; 4 | 5 | import * as utils from 'rian/utils'; 6 | 7 | const mock_scope = () => ({ 8 | span: spy(mock_scope), 9 | end: spy(), 10 | set_context: spy(), 11 | __add_promise: spy(), 12 | }); 13 | 14 | test('exports', () => { 15 | assert.type(utils.measure, 'function'); 16 | }); 17 | 18 | test('should retain `this` context', async () => { 19 | const scope = mock_scope(); 20 | 21 | const fn = { 22 | val: 'foobar', 23 | run() { 24 | return this.val; 25 | }, 26 | }; 27 | 28 | const result = utils.measure((scope as any).span('test'), fn.run.bind(fn)); 29 | assert.equal(result, 'foobar'); 30 | }); 31 | 32 | test('should call .end()', async () => { 33 | const scope = mock_scope(); 34 | // @ts-expect-error 35 | scope.span = () => scope; // keep the current scope so we can assert it 36 | 37 | assert.equal( 38 | utils.measure((scope as any).span('test'), () => 'hello'), 39 | 'hello', 40 | ); 41 | assert.equal(scope.end.callCount, 1); 42 | 43 | assert.equal( 44 | await utils.measure( 45 | (scope as any).span('test'), 46 | () => new Promise((res) => setTimeout(() => res('hello'))), 47 | ), 48 | 'hello', 49 | ); 50 | 51 | assert.equal(scope.__add_promise.callCount, 1); 52 | await scope.__add_promise.calls[0]; 53 | assert.equal(scope.end.callCount, 2); 54 | }); 55 | 56 | const returns = suite('returns'); 57 | 58 | returns('sync', () => { 59 | const scope = mock_scope(); 60 | 61 | assert.equal( 62 | utils.measure((scope as any).span('test'), () => 'test'), 63 | 'test', 64 | ); 65 | assert.equal( 66 | utils.measure((scope as any).span('test'), () => 1), 67 | 1, 68 | ); 69 | assert.equal( 70 | utils.measure((scope as any).span('test'), () => false), 71 | false, 72 | ); 73 | assert.instance( 74 | utils.measure((scope as any).span('test'), () => new Error('test')), 75 | Error, 76 | 'error want thrown, so should just return', 77 | ); 78 | }); 79 | 80 | returns('async', async () => { 81 | const scope = mock_scope(); 82 | 83 | assert.instance( 84 | utils.measure((scope as any).span('test'), async () => {}), 85 | Promise, 86 | ); 87 | 88 | assert.not.equal( 89 | utils.measure((scope as any).span('test'), async () => 'test'), 90 | 'test', 91 | ); 92 | assert.equal( 93 | await utils.measure((scope as any).span('test'), async () => 'test'), 94 | 'test', 95 | ); 96 | assert.equal( 97 | await utils.measure((scope as any).span('test'), async () => 1), 98 | 1, 99 | ); 100 | assert.equal( 101 | await utils.measure((scope as any).span('test'), async () => false), 102 | false, 103 | ); 104 | assert.instance( 105 | await utils.measure( 106 | (scope as any).span('test'), 107 | async () => new Error('test'), 108 | ), 109 | Error, 110 | 'error want thrown, so should just return', 111 | ); 112 | }); 113 | 114 | const errors = suite('errors'); 115 | 116 | errors('sync', () => { 117 | const scope = mock_scope(); 118 | 119 | assert.throws(() => { 120 | utils.measure((scope as any).span('test'), () => { 121 | throw new Error('test'); 122 | }); 123 | }); 124 | }); 125 | 126 | errors('async', async () => { 127 | const scope = mock_scope(); 128 | 129 | try { 130 | await utils.measure((scope as any).span('test'), async () => { 131 | return new Promise((_resolve, rejects) => { 132 | rejects('test'); 133 | }); 134 | }); 135 | assert.unreachable(); 136 | } catch (e) { 137 | assert.type(e, 'string'); 138 | } 139 | 140 | try { 141 | await utils.measure((scope as any).span('test'), async () => { 142 | throw new Error('test'); 143 | }); 144 | assert.unreachable(); 145 | } catch (e) { 146 | assert.instance(e, Error); 147 | } 148 | }); 149 | 150 | test.run(); 151 | returns.run(); 152 | errors.run(); 153 | -------------------------------------------------------------------------------- /bench/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | InMemorySpanExporter, 3 | SimpleSpanProcessor, 4 | } from '@opentelemetry/sdk-trace-base'; 5 | import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; 6 | import { Suite } from 'benchmark'; 7 | 8 | import * as rian from '..'; 9 | import * as rianAsync from '../async'; 10 | import * as assert from 'uvu/assert'; 11 | 12 | async function runner( 13 | name: string, 14 | candidates: Record< 15 | string, 16 | { setup?: () => any; fn: (...args: any[]) => any } 17 | >, 18 | valid: (p: any) => boolean, 19 | ) { 20 | console.log('\nValidation :: %s', name); 21 | for (let name of Object.keys(candidates)) { 22 | const candidate = candidates[name]; 23 | const result = await candidate.fn(candidate.setup?.()); 24 | try { 25 | assert.ok(valid(result), `${result} is not ok`); 26 | console.log(`✔`, name); 27 | } catch (err) { 28 | console.log('✘', name, `(FAILED @ "${err.message}")`); 29 | } 30 | } 31 | 32 | return new Promise((resolve) => { 33 | console.log('\nBenchmark :: %s', name); 34 | const bench = new Suite().on('cycle', (e) => 35 | console.log(' ' + e.target), 36 | ); 37 | Object.keys(candidates).forEach((name) => { 38 | const setup = candidates[name].setup?.(); 39 | bench.add(name + ' '.repeat(22 - name.length), { 40 | fn: () => candidates[name].fn(setup), 41 | async: true, 42 | }); 43 | }); 44 | 45 | bench.on('complete', resolve); 46 | 47 | bench.run(); 48 | }); 49 | } 50 | 51 | const opentelemetrySetup = () => { 52 | const tracerProvider = new NodeTracerProvider(); 53 | const exporter = new InMemorySpanExporter(); 54 | tracerProvider.addSpanProcessor(new SimpleSpanProcessor(exporter)); 55 | 56 | return { 57 | tracerProvider, 58 | exporter, 59 | }; 60 | }; 61 | 62 | (async function () { 63 | await runner( 64 | 'single span', 65 | { 66 | rian: { 67 | fn: async () => { 68 | const tracer = rian.tracer('test'); 69 | 70 | tracer.span('span 1').end(); 71 | 72 | return await rian.report( 73 | (s) => Array.from(s.scopeSpans)[0].spans, 74 | ); 75 | }, 76 | }, 77 | 'rian/async': { 78 | fn: async () => { 79 | rianAsync.tracer('test')(() => { 80 | rianAsync.span('span 1').end(); 81 | }); 82 | 83 | return await rianAsync.report( 84 | (s) => Array.from(s.scopeSpans)[0].spans, 85 | ); 86 | }, 87 | }, 88 | opentelemetry: { 89 | setup: opentelemetrySetup, 90 | fn: async ({ 91 | tracerProvider, 92 | exporter, 93 | }: ReturnType) => { 94 | tracerProvider.getTracer('test').startSpan('span 1').end(); 95 | await tracerProvider.forceFlush(); 96 | 97 | return exporter.getFinishedSpans(); 98 | }, 99 | }, 100 | }, 101 | (s) => { 102 | let spans: any[] = s; 103 | if (s instanceof Set) spans = Array.from(spans); 104 | return spans.length === 1; 105 | }, 106 | ); 107 | 108 | await runner( 109 | 'child span', 110 | { 111 | rian: { 112 | fn: async () => { 113 | const tracer = rian.tracer('test'); 114 | 115 | tracer.span('span 1')((s) => { 116 | s.span('span 2').end(); 117 | }); 118 | 119 | return await rian.report( 120 | (s) => Array.from(s.scopeSpans)[0].spans, 121 | ); 122 | }, 123 | }, 124 | 'rian/async': { 125 | fn: async () => { 126 | rianAsync.tracer('test')(() => { 127 | rianAsync.span('span 1')(() => { 128 | rianAsync.span('span 2').end(); 129 | }); 130 | }); 131 | 132 | return await rianAsync.report( 133 | (s) => Array.from(s.scopeSpans)[0].spans, 134 | ); 135 | }, 136 | }, 137 | opentelemetry: { 138 | setup: opentelemetrySetup, 139 | fn: async ({ 140 | tracerProvider, 141 | exporter, 142 | }: ReturnType) => { 143 | const tracer = tracerProvider.getTracer('test'); 144 | 145 | tracer.startActiveSpan('span 1', (span) => { 146 | const span2 = tracer.startSpan('span 2'); 147 | span2.end(); 148 | span.end(); 149 | }); 150 | 151 | await tracerProvider.forceFlush(); 152 | 153 | return exporter.getFinishedSpans(); 154 | }, 155 | }, 156 | }, 157 | (s) => { 158 | let spans: any[] = s; 159 | if (s instanceof Set) spans = Array.from(spans); 160 | return spans.length === 2; 161 | }, 162 | ); 163 | })().catch((e) => { 164 | console.error(e); 165 | process.exit(1); 166 | }); 167 | -------------------------------------------------------------------------------- /src/exporter.otel.http.ts: -------------------------------------------------------------------------------- 1 | import type * as rian from 'rian'; 2 | 3 | type KeyValue = { 4 | key: string; 5 | value: AnyValue; 6 | }; 7 | 8 | type ArrayValue = { 9 | values: AnyValue[]; 10 | }; 11 | 12 | type KeyValueList = { 13 | values: KeyValue[]; 14 | }; 15 | 16 | type AnyValue = { 17 | stringValue?: string; 18 | boolValue?: boolean; 19 | intValue?: number; 20 | doubleValue?: number; 21 | arrayValue?: ArrayValue; 22 | kvlistValue?: KeyValueList; 23 | }; 24 | 25 | const SpanStatusCode_UNSET = 0; 26 | const SpanStatusCode_OK = 1; 27 | const SpanStatusCode_ERROR = 2; 28 | 29 | type Status = { 30 | code: 31 | | typeof SpanStatusCode_UNSET 32 | | typeof SpanStatusCode_OK 33 | | typeof SpanStatusCode_ERROR; 34 | message?: string; 35 | }; 36 | 37 | interface Span { 38 | traceId: string; 39 | spanId: string; 40 | parentSpanId: string | undefined; 41 | 42 | traceState?: string; 43 | 44 | name?: string; 45 | kind?: number; 46 | 47 | startTimeUnixNano?: number; 48 | endTimeUnixNano: number | undefined; 49 | attributes?: KeyValue[] | Record; 50 | 51 | status?: Status; 52 | 53 | events?: { 54 | timeUnixNano: number; 55 | name: string; 56 | droppedAttributesCount: number; 57 | attributes?: KeyValue[]; 58 | }[]; 59 | 60 | droppedAttributesCount: number; 61 | droppedEventsCount: number; 62 | droppedLinksCount: number; 63 | } 64 | 65 | const convert_value_to_anyvalue = (value: any) => { 66 | let type = typeof value, 67 | any_value: AnyValue = {}; 68 | 69 | if (type === 'string') any_value.stringValue = value; 70 | else if (type === 'number') 71 | if (Number.isInteger(value)) any_value.intValue = value; 72 | else any_value.doubleValue = value; 73 | else if (type === 'boolean') any_value.boolValue = value; 74 | else if (Array.isArray(value)) 75 | any_value.arrayValue = { 76 | values: value.map((i) => convert_value_to_anyvalue(i)), 77 | }; 78 | else if (value) 79 | any_value.kvlistValue = { values: convert_object_to_kv(value) }; 80 | 81 | return any_value; 82 | }; 83 | 84 | const convert_object_to_kv = (input: any) => { 85 | const value: KeyValue[] = []; 86 | 87 | for (let key of Object.keys(input)) { 88 | value.push({ 89 | key, 90 | value: convert_value_to_anyvalue(input[key]), 91 | }); 92 | } 93 | 94 | return value; 95 | }; 96 | 97 | // https://github.com/open-telemetry/opentelemetry-proto/blob/b43e9b18b76abf3ee040164b55b9c355217151f3/opentelemetry/proto/trace/v1/trace.proto#L127-L155 98 | const map_kind = (kind: any): number => { 99 | switch (kind) { 100 | default: 101 | case 'INTERNAL': { 102 | return 1; 103 | } 104 | case 'SERVER': { 105 | return 2; 106 | } 107 | case 'CLIENT': { 108 | return 3; 109 | } 110 | case 'PRODUCER': { 111 | return 4; 112 | } 113 | case 'CONSUMER': { 114 | return 5; 115 | } 116 | } 117 | }; 118 | 119 | export const exporter = 120 | (request: (payload: any) => any): rian.Exporter => 121 | (trace) => { 122 | const scopeSpans: { 123 | scope: rian.ScopedSpans['scope']; 124 | spans: Span[]; 125 | }[] = []; 126 | 127 | for (let scope of trace.scopeSpans) { 128 | const spans: Span[] = []; 129 | 130 | scopeSpans.push({ 131 | scope: scope.scope, 132 | spans, 133 | }); 134 | 135 | for (let span of scope.spans) { 136 | const { kind, error, ...span_ctx } = span.context; 137 | 138 | let status: Status; 139 | if (error) { 140 | status = { 141 | code: SpanStatusCode_ERROR, 142 | }; 143 | 144 | if ('message' in (error as Error)) { 145 | status.message = error.message; 146 | } 147 | } 148 | 149 | spans.push({ 150 | traceId: span.id.trace_id, 151 | spanId: span.id.parent_id, 152 | parentSpanId: span.parent?.parent_id, 153 | 154 | name: span.name, 155 | kind: map_kind(kind || 'INTERNAL'), 156 | 157 | startTimeUnixNano: span.start * 1000000, 158 | endTimeUnixNano: span.end ? span.end * 1000000 : undefined, 159 | 160 | droppedAttributesCount: 0, 161 | droppedEventsCount: 0, 162 | droppedLinksCount: 0, 163 | 164 | attributes: convert_object_to_kv(span_ctx), 165 | 166 | // @ts-expect-error TS2454 167 | status: status || { code: SpanStatusCode_UNSET }, 168 | 169 | events: span.events.map((i) => ({ 170 | name: i.name, 171 | attributes: convert_object_to_kv(i.attributes), 172 | droppedAttributesCount: 0, 173 | timeUnixNano: i.timestamp * 1000000, 174 | })), 175 | }); 176 | } 177 | } 178 | 179 | return request({ 180 | resourceSpans: [ 181 | { 182 | resource: { 183 | attributes: convert_object_to_kv(trace.resource), 184 | droppedAttributesCount: 0, 185 | }, 186 | scopeSpans, 187 | }, 188 | ], 189 | }); 190 | }; 191 | -------------------------------------------------------------------------------- /src/exporter.console.ts: -------------------------------------------------------------------------------- 1 | import type * as rian from 'rian'; 2 | 3 | let p = 1; 4 | 5 | export function exporter(max_cols = 120) { 6 | if (max_cols < 24) throw new Error('max_cols must be at least 24'); 7 | 8 | return function (trace: rian.Trace) { 9 | console.log(obj_string(trace.resource) + '─'.repeat(max_cols)); 10 | 11 | max_cols = max_cols - 2; 12 | for (let scope of trace.scopeSpans) { 13 | let spans = scope.spans; 14 | 15 | if (!spans.length) return; 16 | 17 | let tmp, i; 18 | 19 | let max_time = 0; 20 | let min_time = spans[0].start; 21 | 22 | for (i = 0; (tmp = scope.spans[i++]); ) { 23 | max_time = Math.max(max_time, tmp.end ?? tmp.start); 24 | min_time = Math.min(min_time, tmp.start); 25 | } 26 | 27 | let t_dur = max_time - min_time; 28 | let t_dur_str = format(t_dur); 29 | 30 | // TODO: perform these calculations across all spans in all scopes so the boxes align 31 | 32 | // [ cols ] 33 | // { time } 34 | let max_time_length = t_dur_str.length; 35 | let max_time_col = max_time_length + 2; // .| 36 | 37 | // [ time ] { trace } 38 | let max_trace_col = Math.ceil((2 / 3) * (max_cols - max_time_col)); 39 | let trace_cols = max_trace_col - (p * 2 + 2); 40 | 41 | // [ time ] [ trace ] { name } 42 | let max_name_col = max_cols - max_time_col - max_trace_col; 43 | 44 | // [...^...] 45 | let mid = Math.ceil(trace_cols / 2); 46 | let mid_str = format(t_dur / 2); 47 | let mid_str_anchor = Math.ceil(mid_str.length / 2); 48 | 49 | // RENDER 50 | 51 | let out = ''; 52 | 53 | out += '╭─ '; 54 | out += scope.scope.name; 55 | out += '\n'; 56 | 57 | // spans top border 58 | out += '│ '; 59 | out += '╭'.padStart(max_time_col); 60 | out += '─'.repeat(max_trace_col); 61 | out += '╮\n'; 62 | 63 | // render spans 64 | for (i = 0; (tmp = scope.spans[i++]); ) { 65 | let start_time = tmp.start - min_time; 66 | let end_time = (tmp.end ?? max_time) - min_time; 67 | 68 | let start_trace = Math.ceil((start_time / t_dur) * trace_cols); 69 | let end_trace = Math.ceil((end_time / t_dur) * trace_cols); 70 | 71 | let dur = end_time - start_time; 72 | let dur_str = format(dur); 73 | 74 | // time 75 | out += '│ '; 76 | out += dur_str.padStart(max_time_length); 77 | out += ' │'; 78 | 79 | // trace 80 | out += ' '.repeat(start_trace + p); 81 | out += '┣'; 82 | out += (tmp.end ? '━' : '╍').repeat(end_trace - start_trace); 83 | out += '┫'; 84 | out += ' '.repeat(max_trace_col - end_trace - (p + 2)); 85 | 86 | // name 87 | out += '│◗ '; 88 | out += 89 | tmp.name.length + 4 > max_name_col 90 | ? tmp.name.substring(0, max_name_col - 4) + '…' 91 | : tmp.name; 92 | 93 | out += '\n'; 94 | } 95 | 96 | // spans bottom border 97 | out += '│ '; 98 | out += '╰'.padStart(max_time_col); 99 | out += '┼'; 100 | out += '┴'.repeat(mid - 2); 101 | out += '┼'; 102 | out += '┴'.repeat(max_trace_col - mid - 1); 103 | out += '┼'; 104 | out += '╯\n'; 105 | 106 | // legend 107 | out += '│ '; 108 | out += '0 ms'.padStart(max_time_length + 2 + 4); // .[0 ms 109 | out += mid_str.padStart(mid + mid_str_anchor - 4); // 0 ms 110 | out += t_dur_str.padStart( 111 | trace_cols - 112 | mid + 113 | 2 - // . . 114 | (mid_str_anchor + 4) + // 0 ms 115 | t_dur_str.length, 116 | ); 117 | 118 | out += '\n│\n'; 119 | 120 | // trailer 121 | out += '│ '; 122 | let t_dur_str_seg = format(t_dur / trace_cols); 123 | let t_max_len = Math.max(t_dur_str_seg.length, t_dur_str.length); 124 | out += tmp = `one └┘ unit is less than: ${t_dur_str_seg}\n`; 125 | out += '│ '; 126 | out += `total time: ${t_dur_str.padStart(t_max_len)}`.padStart( 127 | tmp.length - 1, 128 | ); 129 | 130 | out += '\n╰─'; 131 | 132 | console.log(out); 133 | } 134 | }; 135 | } 136 | 137 | // -- 138 | 139 | function obj_string(obj: Record, line_prefix = '') { 140 | let keys = Object.keys(obj); 141 | 142 | let tmp, i; 143 | let max_key = 0; 144 | 145 | for (i = 0; (tmp = keys[i++]); max_key = Math.max(max_key, tmp.length)); 146 | 147 | let out = ''; 148 | for ( 149 | i = 0; 150 | (tmp = keys[i++]); 151 | out += line_prefix + tmp.padStart(max_key) + ': ' + obj[tmp] + '\n' 152 | ); 153 | return out; 154 | } 155 | 156 | let MIN = 60e3; 157 | let HOUR = MIN * 60; 158 | let SEC = 1e3; 159 | 160 | function dec_str(num: number) { 161 | return num % 1 === 0 ? String(num) : num.toFixed(3); 162 | } 163 | 164 | function format(num: number) { 165 | if (num < 0) return '0 ms'; 166 | if (num < SEC) return `${dec_str(num)} ms`; 167 | if (num < MIN) return `${dec_str(num / SEC)} s`; 168 | if (num < HOUR) { 169 | let m = Math.floor(num / MIN); 170 | let s = Math.floor((num % MIN) / SEC); 171 | let ms = dec_str(num % SEC); 172 | return `${m} m ${s} s ${ms} ms`; 173 | } 174 | 175 | return '> 1hr'; 176 | } 177 | -------------------------------------------------------------------------------- /src/async.test.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from 'node:async_hooks'; 2 | 3 | import { suite, test } from 'uvu'; 4 | import * as assert from 'uvu/assert'; 5 | 6 | import { make } from 'tctx/traceparent'; 7 | 8 | import type { Exporter } from 'rian'; 9 | import * as rian from 'rian/async'; 10 | 11 | const scope_spans = (scopes: Parameters[0]) => { 12 | return Array.from(scopes.scopeSpans).flatMap((scope) => scope.spans); 13 | }; 14 | 15 | test('api', () => { 16 | assert.type(rian.currentSpan, 'function'); 17 | assert.type(rian.report, 'function'); 18 | assert.type(rian.tracer, 'function'); 19 | assert.type(rian.span, 'function'); 20 | assert.type(rian.configure, 'function'); 21 | }); 22 | 23 | test('works', async () => { 24 | const value = await rian.tracer('tracer')(() => { 25 | const scope = rian.span('span 1'); 26 | 27 | scope.set_context({ 28 | baz: 'qux', 29 | }); 30 | 31 | return scope(() => { 32 | const span = rian.currentSpan(); 33 | 34 | span.set_context({ 35 | foo: 'bar', 36 | }); 37 | 38 | rian.span('span 2')(() => {}); 39 | 40 | return 'test'; 41 | }); 42 | }); 43 | 44 | assert.is(value, 'test'); 45 | 46 | const spans = await rian.report(scope_spans); 47 | 48 | assert.is(spans.length, 2); 49 | assert.is(spans[0].name, 'span 1'); 50 | assert.is(spans[1].name, 'span 2'); 51 | }); 52 | 53 | test('can mix with third-party async_hooks', async () => { 54 | const store = new AsyncLocalStorage(); 55 | 56 | const value = await rian.tracer('tracer')(() => { 57 | return rian.span('span 1')(() => { 58 | return store.run('frank', () => { 59 | const span = rian.currentSpan(); 60 | 61 | span.set_context({ 62 | foo: 'bar', 63 | }); 64 | 65 | return rian.span('read')(() => store.getStore()); 66 | }); 67 | }); 68 | }); 69 | 70 | assert.is(value, 'frank'); 71 | 72 | const spans = await rian.report(scope_spans); 73 | 74 | assert.is(spans.length, 2); 75 | assert.is(spans[0].name, 'span 1'); 76 | assert.is(spans[1].name, 'read'); 77 | 78 | assert.is(spans[0].context.foo, 'bar'); 79 | }); 80 | 81 | const tracer = suite('tracer'); 82 | 83 | tracer('api', () => { 84 | assert.type(rian.tracer('test'), 'function'); 85 | }); 86 | 87 | tracer('can create spans', async () => { 88 | rian.tracer('test')(() => { 89 | rian.span('some-name')(() => {}); 90 | rian.span('some-other-name').end(); 91 | }); 92 | 93 | const spans = await rian.report(scope_spans); 94 | assert.is(spans.length, 2); 95 | }); 96 | 97 | tracer('can recieve a root_id', async () => { 98 | const id = make(); 99 | const one = rian.tracer('one'); 100 | const two = rian.tracer('two'); 101 | 102 | one(() => { 103 | rian.span('child 1')(() => {}); 104 | 105 | two(() => { 106 | rian.span( 107 | 'child 2', 108 | id, 109 | )(() => { 110 | one(() => { 111 | rian.span('child 3')(() => {}); 112 | }); 113 | }); 114 | }); 115 | }); 116 | 117 | const spans = await rian.report(scope_spans); 118 | assert.is(spans.length, 3); 119 | assert.is(spans[0].name, 'child 1'); 120 | assert.is(spans[1].name, 'child 3'); 121 | assert.is(spans[2].name, 'child 2'); 122 | 123 | assert.is(spans[0].parent, undefined); 124 | assert.is(String(spans[2].parent), String(id), 'was given id, use it'); 125 | assert.is(spans[1].parent, spans[2].id); 126 | }); 127 | 128 | tracer('can nest spans', async () => { 129 | rian.tracer('test')(() => { 130 | rian.span('parent')(() => { 131 | rian.span('child')(() => {}); 132 | }); 133 | }); 134 | 135 | const spans = await rian.report(scope_spans); 136 | assert.is(spans.length, 2); 137 | assert.is(spans[0].name, 'parent'); 138 | assert.is(spans[1].name, 'child'); 139 | 140 | assert.is(spans[0].parent, undefined); 141 | assert.is(spans[1].parent, spans[0].id); 142 | }); 143 | 144 | tracer('correctly parents tracers', async () => { 145 | const one = rian.tracer('one'); 146 | const two = rian.tracer('two'); 147 | 148 | one(() => { 149 | rian.span('child 1')(() => { 150 | two(() => { 151 | rian.span('child 2')(async () => {}); 152 | }); 153 | }); 154 | 155 | rian.span('child 3')(() => {}); 156 | }); 157 | 158 | const scopes = await rian.report((scopes) => scopes); 159 | const scoped_spans = Array.from(scopes.scopeSpans); 160 | const spans = scoped_spans.flatMap((scope) => scope.spans); 161 | 162 | assert.is(spans.length, 3); 163 | assert.is(spans[0].name, 'child 1'); 164 | assert.is(spans[1].name, 'child 3'); 165 | assert.is(spans[2].name, 'child 2'); 166 | 167 | assert.is(scoped_spans.length, 2); 168 | assert.is(scoped_spans[0].scope.name, 'one'); 169 | assert.is(scoped_spans[0].spans.length, 2); 170 | assert.is(scoped_spans[1].scope.name, 'two'); 171 | assert.is(scoped_spans[1].spans.length, 1); 172 | 173 | assert.is(spans[0].parent, undefined); 174 | assert.is(spans[2].parent, spans[0].id, 'should pierce tracers'); 175 | assert.is(spans[1].parent, undefined); 176 | }); 177 | 178 | tracer('can be called again', async () => { 179 | const one = rian.tracer('one'); 180 | const two = rian.tracer('two'); 181 | 182 | one(() => { 183 | rian.span('child 1')(() => { 184 | two(() => { 185 | one(() => { 186 | rian.span('child 2')(async () => {}); 187 | }); 188 | }); 189 | }); 190 | 191 | two(() => { 192 | rian.span('child 3')(() => {}); 193 | }); 194 | }); 195 | 196 | const spans = await rian.report(scope_spans); 197 | assert.is(spans.length, 3); 198 | assert.is(spans[0].name, 'child 1'); 199 | assert.is(spans[1].name, 'child 2'); 200 | assert.is(spans[2].name, 'child 3'); 201 | 202 | assert.is(spans[0].parent, undefined); 203 | assert.is(spans[1].parent, spans[0].id, 'should pierce tracers'); 204 | assert.is(spans[2].parent, undefined); 205 | }); 206 | 207 | tracer('collects spans between reports', async () => { 208 | const one = rian.tracer('one'); 209 | one(() => { 210 | rian.span('span 1').end(); 211 | }); 212 | 213 | { 214 | const spans = await rian.report(scope_spans); 215 | assert.is(spans.length, 1); 216 | assert.is(spans[0].name, 'span 1'); 217 | } 218 | 219 | one(() => { 220 | rian.span('span 2').end(); 221 | }); 222 | 223 | { 224 | const spans = await rian.report(scope_spans); 225 | assert.is(spans.length, 1); 226 | assert.is(spans[0].name, 'span 2'); 227 | } 228 | }); 229 | 230 | tracer('.span should nest', async () => { 231 | rian.tracer('test')(() => { 232 | rian.span('parent1')((s) => { 233 | s.span('child1').end(); 234 | }); 235 | const s = rian.span('parent2'); 236 | s.span('child2').end(); 237 | s.end(); 238 | }); 239 | 240 | const spans = await rian.report(scope_spans); 241 | assert.is(spans.length, 4); 242 | assert.is(spans[0].name, 'parent1'); 243 | assert.is(spans[1].name, 'child1'); 244 | assert.is(spans[2].name, 'parent2'); 245 | assert.is(spans[3].name, 'child2'); 246 | 247 | assert.is(spans[0].parent, undefined); 248 | assert.is(spans[1].parent, spans[0].id); 249 | assert.is(spans[2].parent, undefined); 250 | assert.is(spans[3].parent, spans[2].id); 251 | }); 252 | 253 | tracer('tracer is called twice', async () => { 254 | const t = rian.tracer('my-tracer'); 255 | 256 | t(async () => { 257 | rian.span('span 1')(async () => {}); 258 | }); 259 | 260 | let spans = await rian.report(scope_spans); 261 | 262 | t(async () => { 263 | rian.span('span 2')(async () => {}); 264 | }); 265 | 266 | spans.push(...(await rian.report(scope_spans))); 267 | 268 | assert.is(spans.length, 2); 269 | }); 270 | 271 | test.run(); 272 | tracer.run(); 273 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Traceparent } from 'tctx/traceparent'; 2 | 3 | // --- tracer 4 | 5 | /** 6 | * The exporter is called when the {@link report} method is called. 7 | */ 8 | export type Exporter = (trace: Trace) => any; 9 | 10 | type Resource = { 11 | 'service.name': string; 12 | 'telemetry.sdk.name': string; 13 | 'telemetry.sdk.version': string; 14 | } & Context; 15 | 16 | export type Trace = { 17 | resource: Resource; 18 | scopeSpans: ReadonlyArray; 19 | }; 20 | 21 | export type ScopedSpans = { 22 | readonly scope: { readonly name: string }; 23 | readonly spans: ReadonlyArray>; 24 | }; 25 | 26 | export type Options = { 27 | /** 28 | * @borrows {@link Sampler} 29 | */ 30 | sampler?: Sampler | boolean; 31 | 32 | clock?: ClockLike; 33 | }; 34 | 35 | export type Tracer = Pick; 36 | 37 | /** 38 | * @borrows {@link Span.context} 39 | */ 40 | export type Context = { 41 | [property: string]: any; 42 | }; 43 | 44 | /** 45 | * Allows a sampling decision to be made. This method will influence the {@link Span.id|traceparent} sampling flag. 46 | * 47 | * Return true if the span should be sampled, and reported to the {@link Exporter}. 48 | * Return false if the span should not be sampled, and not reported to the {@link Exporter}. 49 | */ 50 | export type Sampler = ( 51 | /** 52 | * The id of the new span looking for a sampling decision. 53 | */ 54 | id: string, 55 | /** 56 | * The parent id of the new span looking for a sampling decision. 57 | */ 58 | parent: Traceparent | undefined, 59 | /** 60 | * The name of the span. 61 | */ 62 | name: string, 63 | /** 64 | * The tracer this span belongs to. 65 | */ 66 | tracer: { readonly name: string }, 67 | ) => boolean; 68 | 69 | // --- spans 70 | 71 | /** 72 | * Spans are units within a distributed trace. Spans encapsulate mainly 3 pieces of information, a 73 | * {@link Span.name|name}, and a {@link Span.start|start} and {@link Span.end|end} time. 74 | * 75 | * Each span should be named, not too vague, and not too precise. For example, "resolve_user_ids" 76 | * and not "resolver_user_ids[1,2,3]" nor "resolver". 77 | * 78 | * A span forms part of a wider trace, and can be visualized like: 79 | * 80 | * ```plain 81 | * [Span A················································(2ms)] 82 | * [Span B·········································(1.7ms)] 83 | * [Span D···············(0.8ms)] [Span C......(0.6ms)] 84 | * ``` 85 | */ 86 | export type Span = { 87 | /** 88 | * A human-readable name for this span. For example the function name, the name of a subtask, 89 | * or stage of the larger stack. 90 | * 91 | * @example 92 | * 93 | * "resolve_user_ids" 94 | * "[POST] /api" 95 | */ 96 | name: string; 97 | 98 | /** 99 | * A w3c trace context compatible id for this span. Will .toString() into an injectable header. 100 | * 101 | * @see https://www.w3.org/TR/trace-context/#traceparent-header 102 | * @see https://github.com/maraisr/tctx 103 | */ 104 | id: Traceparent; 105 | 106 | /** 107 | * Is the id of the parent if this is not the parent {@link Span}. 108 | * 109 | * @see {@link Span.id} 110 | */ 111 | parent: Traceparent | undefined; 112 | 113 | /** 114 | * The time represented as a UNIX epoch timestamp in milliseconds when this span was created. 115 | * Typically, via 116 | * {@link SpanBuilder.span|SpanBuilder.span()}. 117 | */ 118 | start: number; 119 | 120 | /** 121 | * The UNIX epoch timestamp in milliseconds when the span ended, or undefined if ending was not 122 | * captured during the current trace. Time should then be assumed as current time. 123 | */ 124 | end?: number; 125 | 126 | /** 127 | * An arbitrary context object useful for storing information during a trace. 128 | * 129 | * Usually following a convention such as `tag.*`, `http.*` or any of the 130 | * {@link https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/|OpenTelemetry Trace Semantic Conventions}. 131 | * 132 | * ### Note! 133 | * 134 | * There are a few keys with "powers" 135 | * 136 | * - `kind` when set will coerce into the exports scheme, aka INTERNAL in zipkin will be 137 | * `"INTERNAL"`, or `1` in otel 138 | * - `error` when set, will be assumed to be an `Error` instance, and thus its `.message` wil 139 | * exist as `error.message` in zipkin, and `status: 2` in otel. 140 | */ 141 | context: Context; 142 | 143 | /** 144 | * Events are user-defined timestamped annotations of "events" that happened during the 145 | * lifetime of a span. Consisting of a textual message, and optional attributes. 146 | * 147 | * As a rule-of-thumb use events to attach verbose information about a span, than an entirely 148 | * new span. 149 | */ 150 | events: { name: string; timestamp: number; attributes: Context }[]; 151 | }; 152 | 153 | // --- scopes 154 | 155 | export type SpanBuilder = { 156 | /** 157 | * A W3C traceparent. One can .toString() this if you want to cross a network. 158 | */ 159 | traceparent: Traceparent; 160 | 161 | /** 162 | * Forks the span into a new child span. 163 | */ 164 | span( 165 | /** 166 | * @borrows {@link Span.name} 167 | */ 168 | name: string, 169 | parent_id?: Traceparent | string, 170 | ): CallableSpanBuilder; 171 | 172 | /** 173 | * Allows the span's context to be set. Passing an object will be `Object.assign`ed into the 174 | * current context. 175 | * 176 | * Passing a function will be available to return a new context. 177 | */ 178 | set_context(contextFn: Context | ((context: Context) => Context)): void; 179 | 180 | /** 181 | * Adds a new event to the span. As a rule-of-thumb use events to attach verbose information 182 | * about a span, than an entirely new span. 183 | */ 184 | add_event(name: string, attributes?: Record): void; 185 | 186 | /** 187 | * Ends the current span — setting its `end` timestamp. Not calling this, will have its `end` 188 | * timestamp nulled out — when the tracer ends. 189 | */ 190 | end(): void; 191 | }; 192 | 193 | export type CallableSpanBuilder = SpanBuilder & { 194 | ) => any>( 195 | cb: Fn, 196 | ): ReturnType; 197 | }; 198 | 199 | // --- main api 200 | 201 | /** 202 | * A tracer is a logical unit in your application. This alleviates the need to pass around a tracer instance. 203 | * 204 | * All spans produced by a tracer will all collect into a single span collection that is given to {@link report}. 205 | * 206 | * @example 207 | * 208 | * ```ts 209 | * // file: server.ts 210 | * const trace = tracer('server'); 211 | * 212 | * // file: orm.ts 213 | * const trace = tracer('orm'); 214 | * 215 | * // file: api.ts 216 | * const trace = tracer('api'); 217 | * ``` 218 | */ 219 | export function tracer(name: string, options?: Options): Tracer; 220 | 221 | // -- general api 222 | 223 | /** 224 | * Awaits all active promises, and then calls the {@link Options.exporter|exporter}. Passing all collected spans. 225 | */ 226 | export function report(exporter: T): Promise>; 227 | 228 | /** 229 | * Calling this method will set the resource attributes for this runtime. This is useful for things like: 230 | * - setting the deployment environment of the application 231 | * - setting the k8s namespace 232 | * - ... 233 | * 234 | * The `name` argument will set the `service.name` attribute. And is required. 235 | * 236 | * The fields can be whatever you want, but it is recommended to follow the {@link https://opentelemetry.io/docs/reference/specification/resource/semantic_conventions/|OpenTelemetry Resource Semantic Conventions}. 237 | * 238 | * @example 239 | * 240 | * ```ts 241 | * configure('my-service', { 'deployment.environment': 'production', 'k8s.namespace.name': 'default' }); 242 | * ``` 243 | */ 244 | export function configure(name: string, attributes?: Context): void; 245 | 246 | /** 247 | * Provinding a clock allows you to control the time of the span. 248 | */ 249 | export type ClockLike = { 250 | /** 251 | * Must return the number of milliseconds since the epoch. 252 | */ 253 | now(): number; 254 | }; 255 | -------------------------------------------------------------------------------- /src/rian.test.ts: -------------------------------------------------------------------------------- 1 | import { spy, spyOn } from 'nanospy'; 2 | import { is_sampled } from 'tctx/traceparent'; 3 | import { suite, test } from 'uvu'; 4 | import * as assert from 'uvu/assert'; 5 | 6 | import * as rian from 'rian'; 7 | import * as utils from 'rian/utils'; 8 | 9 | const noop = () => {}; 10 | 11 | const returns: rian.Exporter = (traces) => { 12 | return Array.from(traces.scopeSpans); 13 | }; 14 | 15 | test('exports', () => { 16 | assert.type(rian.tracer, 'function'); 17 | assert.type(rian.report, 'function'); 18 | }); 19 | 20 | test('api', async () => { 21 | const tracer = rian.tracer('test'); 22 | 23 | const scope = tracer.span('some-name'); 24 | 25 | scope.set_context({ 26 | baz: 'qux', 27 | }); 28 | 29 | scope((scope) => { 30 | scope.set_context({ 31 | foo: 'bar', 32 | }); 33 | 34 | return 'test'; 35 | }); 36 | 37 | const exporter = spy(returns); 38 | const scopedSpans: rian.ScopedSpans[] = await rian.report(exporter); 39 | 40 | assert.equal(exporter.callCount, 1); 41 | assert.equal(scopedSpans.length, 1); 42 | }); 43 | 44 | test('context', async () => { 45 | const tracer = rian.tracer('test'); 46 | 47 | const span = tracer.span('context'); 48 | 49 | span.set_context({ 50 | one: 'one', 51 | }); 52 | 53 | span.set_context((ctx) => ({ [ctx.one]: 'two' })); 54 | 55 | span.set_context({ 56 | three: 'three', 57 | }); 58 | 59 | span.end(); 60 | 61 | const scopedSpans: rian.ScopedSpans[] = await rian.report(returns); 62 | assert.equal(scopedSpans.length, 1); 63 | const spans = scopedSpans[0].spans; 64 | 65 | assert.equal(spans.length, 1); 66 | assert.equal(Array.from(spans)[0].context, { 67 | one: 'two', 68 | three: 'three', 69 | }); 70 | }); 71 | 72 | test('has offset start and end times', async () => { 73 | let called = -1; 74 | spyOn(Date, 'now', () => ++called); 75 | 76 | const tracer = rian.tracer('test', { 77 | clock: { now: () => Date.now() + 5 }, 78 | }); 79 | 80 | tracer.span('test')(() => { 81 | tracer.span('test')(() => {}); 82 | }); 83 | 84 | const scopedSpans: rian.ScopedSpans[] = await rian.report(returns); 85 | assert.equal(scopedSpans.length, 1); 86 | const spans = scopedSpans[0].spans; 87 | 88 | assert.equal(spans.length, 2); 89 | const arr = Array.from(spans); 90 | 91 | // 2 spans, 2 calls per span 92 | assert.equal(arr[0].start, 5); 93 | assert.equal(arr[0].end, 8); 94 | assert.equal(arr[1].start, 6); 95 | assert.equal(arr[1].end, 7); 96 | }); 97 | 98 | test('promise returns', async () => { 99 | const tracer = rian.tracer('test'); 100 | 101 | const prom = new Promise((resolve) => setTimeout(resolve, 0)); 102 | 103 | // Don't await here so we can assert the __add__promise worked 104 | tracer.span('test')(() => prom); 105 | 106 | const scopedSpans: rian.ScopedSpans[] = await rian.report(returns); 107 | assert.equal(scopedSpans.length, 1); 108 | const spans = scopedSpans[0].spans; 109 | 110 | assert.equal(spans.length, 1); 111 | }); 112 | 113 | const fn = suite('fn'); 114 | 115 | fn('api', async () => { 116 | const tracer = rian.tracer('test'); 117 | 118 | tracer.span('forked')(spy()); 119 | 120 | const exporter = spy(returns); 121 | const scopedSpans: rian.ScopedSpans[] = await rian.report(exporter); 122 | assert.equal(scopedSpans.length, 1); 123 | 124 | const spans = scopedSpans[0].spans; 125 | assert.equal(exporter.callCount, 1); 126 | 127 | assert.equal(spans.length, 1); 128 | }); 129 | 130 | const measure = suite('measure'); 131 | 132 | measure('throw context', async () => { 133 | const tracer = rian.tracer('test'); 134 | 135 | assert.throws(() => 136 | utils.measure(tracer.span('test'), () => { 137 | throw new Error('test'); 138 | }), 139 | ); 140 | 141 | const exporter = spy(returns); 142 | const scopedSpans: rian.ScopedSpans[] = await rian.report(exporter); 143 | assert.equal(scopedSpans.length, 1); 144 | 145 | const spans = scopedSpans[0].spans; 146 | assert.equal(exporter.callCount, 1); 147 | 148 | assert.equal(spans.length, 1); 149 | 150 | assert.instance(Array.from(spans)[0].context.error, Error); 151 | }); 152 | 153 | const sampled = suite('sampling'); 154 | 155 | sampled('default :: no parent should be sampled', async () => { 156 | const tracer = rian.tracer('test'); 157 | 158 | tracer.span('test')(noop); 159 | 160 | const exporter = spy(returns); 161 | const scopedSpans: rian.ScopedSpans[] = await rian.report(exporter); 162 | assert.equal(scopedSpans.length, 1); 163 | 164 | const spans = scopedSpans[0].spans; 165 | assert.equal(exporter.callCount, 1); 166 | 167 | assert.equal(spans.length, 1); 168 | assert.ok( 169 | Array.from(spans).every((i) => is_sampled(i.id)), 170 | 'every id should be sampled', 171 | ); 172 | }); 173 | 174 | const events = suite('events'); 175 | 176 | events('api', async () => { 177 | const tracer = rian.tracer('test'); 178 | 179 | const span = tracer.span('work'); 180 | 181 | span.add_event('work'); 182 | span.add_event('work', { 183 | foo: 'bar', 184 | }); 185 | 186 | span.end(); 187 | 188 | const exporter = spy(returns); 189 | const scopedSpans: rian.ScopedSpans[] = await rian.report(exporter); 190 | assert.equal(scopedSpans.length, 1); 191 | 192 | const spans = Array.from(scopedSpans[0].spans); 193 | assert.equal(exporter.callCount, 1); 194 | 195 | assert.equal(spans.length, 1); 196 | assert.equal(spans[0].events.length, 2); 197 | assert.equal(spans[0].events[0].attributes, {}); 198 | assert.equal(spans[0].events[1].attributes, { foo: 'bar' }); 199 | }); 200 | 201 | const buffer = suite('buffer'); 202 | 203 | buffer('flush all', async () => { 204 | const first = rian.tracer('first'); 205 | first.span('span 1').end(); 206 | 207 | { 208 | const scopedSpans: rian.ScopedSpans[] = await rian.report(returns); 209 | 210 | assert.equal(scopedSpans.length, 1); 211 | const spans = scopedSpans[0].spans; 212 | assert.equal(spans.length, 1); 213 | } 214 | 215 | first.span('span 1.1').end(); 216 | 217 | const second = rian.tracer('second'); 218 | second.span('span 2').end(); 219 | 220 | { 221 | const scopedSpans: rian.ScopedSpans[] = await rian.report(returns); 222 | 223 | assert.equal(scopedSpans.length, 2); 224 | var spans = scopedSpans.flatMap((i) => Array.from(i.spans)); 225 | assert.equal(spans.length, 2); 226 | } 227 | 228 | assert.equal(spans[0].name, 'span 1.1'); 229 | assert.equal(spans[1].name, 'span 2'); 230 | }); 231 | 232 | const sampler = suite('sampler'); 233 | 234 | sampler('should allow a sampler', async () => { 235 | let should_sample = false; 236 | 237 | const tracer = rian.tracer('test', { 238 | sampler: () => should_sample, 239 | }); 240 | 241 | tracer.span('not sampled')(() => {}); 242 | should_sample = true; 243 | tracer.span('sampled')(() => {}); 244 | 245 | const exporter = spy(returns); 246 | const scopedSpans: rian.ScopedSpans[] = await rian.report(exporter); 247 | 248 | assert.equal(scopedSpans.length, 1); 249 | assert.equal(scopedSpans.at(0)!.spans.length, 1); 250 | assert.equal(scopedSpans.at(0)?.spans.at(0)?.name, 'sampled'); 251 | }); 252 | 253 | sampler('allow a sampler to make a decision from its parent', async () => { 254 | let no_parent_sample = true; 255 | 256 | const S: rian.Sampler = (_id, parentId) => { 257 | if (!parentId) return no_parent_sample; 258 | return is_sampled(parentId); 259 | }; 260 | 261 | const tracer = rian.tracer('test', { sampler: S }); 262 | 263 | tracer.span('sampled#1')((s) => { 264 | no_parent_sample = false; 265 | s.span('sampled#1.1')(() => {}); 266 | }); 267 | 268 | no_parent_sample = false; 269 | tracer.span('not sampled#1')((s) => { 270 | s.span('not sampled#1.1')(() => {}); 271 | }); 272 | 273 | no_parent_sample = true; 274 | tracer.span('sampled#2')((s) => { 275 | s.span('sampled#2.1')(() => {}); 276 | }); 277 | 278 | no_parent_sample = false; 279 | 280 | const exporter = spy(returns); 281 | const scopedSpans: rian.ScopedSpans[] = await rian.report(exporter); 282 | 283 | assert.equal(scopedSpans.length, 1); 284 | assert.equal(scopedSpans.at(0)!.spans.length, 4); 285 | assert.equal( 286 | scopedSpans.at(0)!.spans.map((s) => s.name), 287 | ['sampled#1', 'sampled#1.1', 'sampled#2', 'sampled#2.1'], 288 | ); 289 | }); 290 | 291 | test.run(); 292 | fn.run(); 293 | measure.run(); 294 | sampled.run(); 295 | events.run(); 296 | buffer.run(); 297 | sampler.run(); 298 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | rian light mode logo 6 | rian dark mode logo 7 | 8 | 9 | 10 | **A utility to simplify your tracing** 11 | 12 | 13 | js downloads 14 | 15 | 16 | licenses 17 | 18 | 19 | gzip size 20 | 21 | 22 | brotli size 23 | 24 | 25 |
26 |
27 | 28 | 29 | 30 | This is free to use software, but if you do like it, consider supporting me ❤️ 31 | 32 | [![sponsor me](https://badgen.net/badge/icon/sponsor?icon=github&label&color=gray)](https://github.com/sponsors/maraisr) 33 | [![buy me a coffee](https://badgen.net/badge/icon/buymeacoffee?icon=buymeacoffee&label&color=gray)](https://www.buymeacoffee.com/marais) 34 | 35 | 36 | 37 |
38 | 39 | ## ⚡ Features 40 | 41 | - 🤔 **Familiar** — looks very much like opentelemetry. 42 | 43 | - ✅ **Simple** — `configure()` an environment, create a `tracer()`, `report()` and done. 44 | 45 | - 🏎 **Performant** — check the [benchmarks](#-benchmark). 46 | 47 | - 🪶 **Lightweight** — a mere 1KB and next to no [dependencies](https://npm.anvaka.com/#/view/2d/rian/). 48 | 49 | ## 🚀 Usage 50 | 51 | > Visit [/examples](/examples) for more! 52 | 53 | ```ts 54 | import { configure, tracer, report } from 'rian'; 55 | import { exporter } from 'rian/exporter.otel.http'; 56 | 57 | // ~> configure the environment, all tracers will inherit this 58 | configure('my-service', { 59 | 'service.version': 'DEV', 60 | }); 61 | 62 | // ~> create a tracer — typically "per request" or "per operation". 63 | const trace = tracer('request'); 64 | 65 | function handler(req) { 66 | // ~> start a span 67 | return trace.span(`${req.method} ${req.path}`)(async (s) => { 68 | // set some fields on this span's context 69 | s.set_context({ user_id: req.params.user_id }); 70 | 71 | // ~> span again for `db::read` 72 | const data = await s.span('db::read')(() => db_execute('SELECT * FROM users')); 73 | 74 | // ~> maybe have some manual spanning 75 | const processing_span = s.span('process records'); 76 | 77 | for (let row of data) { 78 | processing_span.add_event('doing stuff', { id: row.id }); 79 | do_stuff(row); 80 | } 81 | 82 | // don't forget to end 83 | processing_span.end(); 84 | 85 | return reply(200, { data }); 86 | }); 87 | } 88 | 89 | const otel_exporter = exporter((payload) => 90 | fetch('/traces/otlp', { 91 | method: 'POST', 92 | body: JSON.stringify(payload), 93 | }), 94 | ); 95 | 96 | http.listen((req, executionCtx) => { 97 | // ~> report all the spans once the response is sent 98 | executionCtx.defer(() => report(otel_exporter)); 99 | return handler(req); 100 | }); 101 | ``` 102 | 103 | You only need to `report` in your application once somewhere. All spans are collected into the same "bucket". 104 | 105 |
Example output 106 | 107 | Using: [examples/basic.ts](examples/basic.ts) 108 | 109 | ``` 110 | ╭─ basic 111 | │ ╭─────────────────────────────────────────────────────────────────╮ 112 | │ 95 ms │ ┣━━━━━━━━━━┫ │◗ setup 113 | │ 41 ms │ ┣━━━━┫ │◗ bootstrap 114 | │ 32 ms │ ┣━━━┫ │◗ building 115 | │ 59 ms │ ┣━━━━━┫ │◗ precompile 116 | │ 80 ms │ ┣━━━━━━━━┫ │◗ verify 117 | │ 75 ms │ ┣━━━━━━━┫ │◗ spawn thread 118 | │ 371 ms │ ┣╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍┫ │◗ doesnt finish 119 | │ 347 ms │ ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ │◗ running 120 | │ 341 ms │ ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ │◗ e2e 121 | │ 38 ms │ ┣━━━┫ │◗ snapshot 122 | │ 13 ms │ ┣━┫ │◗ url for page /my-product/sle… 123 | │ ╰┼┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┼┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┼╯ 124 | │ 0 ms 318.500 ms 637 ms 125 | │ 126 | │ one └┘ unit is less than: 10.443 ms 127 | │ total time: 637 ms 128 | ╰─ 129 | ╭─ thread #1 130 | │ ╭──────────────────────────────────────────────────────────────────╮ 131 | │ 20 ms │ ┣━━━━━━━━━━━━━━━━━━━━┫ │◗ setup 132 | │ 63 ms │ ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ │◗ bootstrap 133 | │ ╰┼┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┼┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┼╯ 134 | │ 0 ms 31.500 ms 63 ms 135 | │ 136 | │ one └┘ unit is less than: 1.016 ms 137 | │ total time: 63 ms 138 | ╰─ 139 | ``` 140 | 141 |
142 | 143 | ## 🔎 API 144 | 145 | #### Module: [`rian`](./packages/rian/src/index.ts) 146 | 147 | The main and _default_ module responsible for creating and provisioning spans. 148 | 149 | > 💡 Note ~> when providing span context values, you can use 150 | > [Semantic Conventions](https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/), but won't 151 | > be enforced. 152 | 153 | #### Module: [`rian/async`](./packages/rian/src/async.ts) 154 | 155 | A module that utilizes the `async_hooks` API to provide a `tracer` and `spans` that can be used where the current span 156 | isn't accessible. 157 | 158 | > 💡 Note ~> this module should be used mutually exclusively with the main `rian` module. 159 | 160 |
161 | 162 | Example 163 | 164 | ```ts 165 | import { configure, tracer, span, currentSpan, report } from 'rian/async'; 166 | import { exporter } from 'rian/exporter.otel.http'; 167 | 168 | function handler(req) { 169 | return span(`${req.method} ${req.path}`)(async () => { 170 | const s = currentSpan(); 171 | 172 | s.set_context({ user_id: req.params.user_id }); 173 | 174 | const data = await s.span('db::read')(() => db_execute('SELECT * FROM users')); 175 | 176 | const processing_span = s.span('process records'); 177 | 178 | for (let row of data) { 179 | processing_span.add_event('doing stuff', { id: row.id }); 180 | do_stuff(row); 181 | } 182 | 183 | processing_span.end(); 184 | 185 | return reply(200, { data }); 186 | }); 187 | } 188 | 189 | const httpTrace = tracer('http'); 190 | 191 | http.listen((req, executionCtx) => { 192 | executionCtx.defer(() => report(exporter)); 193 | return httpTrace(() => handler(req)); 194 | }); 195 | ``` 196 | 197 |
198 | 199 | #### Module: [`rian/exporter.zipkin`](./packages/rian/src/exporter.zipkin.ts) 200 | 201 | Exports the spans created using the zipkin protocol and leaves the shipping up to you. 202 | 203 | #### Module: [`rian/exporter.otel.http`](./packages/rian/src/exporter.otel.http.ts) 204 | 205 | Implements the OpenTelemetry protocol for use with http transports. 206 | 207 | ## 🧑‍🍳 Exporter Recipes 208 | 209 |
NewRelic 210 | 211 | ```ts 212 | import { configure, tracer, report } from 'rian'; 213 | import { exporter } from 'rian/exporter.zipkin'; 214 | 215 | const newrelic = exporter((payload) => 216 | fetch('https://trace-api.newrelic.com/trace/v1', { 217 | method: 'POST', 218 | headers: { 219 | 'api-key': '', 220 | 'content-type': 'application/json', 221 | 'data-format': 'zipkin', 222 | 'data-format-version': '2', 223 | }, 224 | body: JSON.stringify(payload), 225 | }), 226 | ); 227 | 228 | configure('my-service'); 229 | 230 | const tracer = tracer('app'); 231 | 232 | await report(newrelic); 233 | ``` 234 | 235 | [learn more](https://docs.newrelic.com/docs/distributed-tracing/trace-api/introduction-trace-api/) 236 | 237 |
238 | 239 |
Lightstep 240 | 241 | ```ts 242 | import { configure, tracer, report } from 'rian'; 243 | import { exporter } from 'rian/exporter.otel.http'; 244 | 245 | const lightstep = exporter((payload) => 246 | fetch('https://ingest.lightstep.com/traces/otlp/v0.9', { 247 | method: 'POST', 248 | headers: { 249 | 'lightstep-access-token': '', 250 | 'content-type': 'application/json', 251 | }, 252 | body: JSON.stringify(payload), 253 | }), 254 | ); 255 | 256 | configure('my-service'); 257 | 258 | const tracer = tracer('app'); 259 | 260 | await report(lightstep); 261 | ``` 262 | 263 | [learn more](https://opentelemetry.lightstep.com/tracing/) 264 | 265 |
266 | 267 | ## 🤔 Motivation 268 | 269 | To clarify, `rian` is the Irish word for "trace". 270 | 271 | In our efforts to be observant citizens, we often rely on tools such as NewRelic, Lightstep, and Datadog. However, these 272 | tools can be bloated and slow, often performing too many unnecessary tasks and driving up costs, as every span costs. 273 | 274 | This is where rian comes in as a lightweight, fast, and effective tracer inspired by industry giants OpenTracing and 275 | OpenTelemetry. These frameworks were designed to abstract the telemetry part from vendors, allowing libraries to be 276 | instrumented without needing to know about the vendor. 277 | 278 | Rian does not intend to align or compete with them, slightly different goals. Rian aims to be used exclusively for 279 | instrumenting your application, particularly critical business paths. While rian can scale to support more complex 280 | constructs, there are profiler tools that are better suited for those jobs. Rian's primary design goal is to provide 281 | better insights into your application's behavior, particularly for edge or service workers where a lean tracer is 282 | favored. 283 | 284 | Rian does not by design handle injecting [`w3c trace-context`](https://www.w3.org/TR/trace-context/), or 285 | [propagating baggage](https://www.w3.org/TR/baggage/). But we do expose api's for achieving this. 286 | 287 | ## 💨 Benchmark 288 | 289 | > via the [`/bench`](/bench) directory with Node v17.2.0 290 | 291 | ``` 292 | Validation :: single span 293 | ✔ rian 294 | ✔ rian/async 295 | ✔ opentelemetry 296 | 297 | Benchmark :: single span 298 | rian x 277,283 ops/sec ±3.57% (90 runs sampled) 299 | rian/async x 279,525 ops/sec ±2.33% (91 runs sampled) 300 | opentelemetry x 155,019 ops/sec ±13.13% (70 runs sampled) 301 | 302 | Validation :: child span 303 | ✔ rian 304 | ✔ rian/async 305 | ✔ opentelemetry 306 | 307 | Benchmark :: child span 308 | rian x 146,793 ops/sec ±3.38% (87 runs sampled) 309 | rian/async x 180,488 ops/sec ±1.64% (92 runs sampled) 310 | opentelemetry x 102,541 ops/sec ±9.77% (73 runs sampled) 311 | ``` 312 | 313 | > And please... I know these results are anything but the full story. But it's a number and point on comparison. 314 | 315 | ## License 316 | 317 | MIT © [Marais Rossouw](https://marais.io) 318 | 319 | ##### Disclaimer 320 | 321 | - NewRelic is a registered trademark of https://newrelic.com/ and not affiliated with this project.
322 | - Datadog is a registered trademark of https://www.datadoghq.com/ and not affiliated with this project.
323 | - Lightstep is a registered trademark of https://lightstep.com/ and not affiliated with this project. 324 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | dependencies: 11 | flattie: 12 | specifier: ^1.1.1 13 | version: 1.1.1 14 | tctx: 15 | specifier: ^0.2.5 16 | version: 0.2.5 17 | devDependencies: 18 | '@marais/prettier': 19 | specifier: 0.0.4 20 | version: 0.0.4 21 | '@marais/tsconfig': 22 | specifier: 0.0.4 23 | version: 0.0.4 24 | '@types/node': 25 | specifier: 24.0.15 26 | version: 24.0.15 27 | bundt: 28 | specifier: 2.0.0-next.5 29 | version: 2.0.0-next.5 30 | nanospy: 31 | specifier: 1.0.0 32 | version: 1.0.0 33 | prettier: 34 | specifier: 3.6.2 35 | version: 3.6.2 36 | tsm: 37 | specifier: 2.3.0 38 | version: 2.3.0 39 | typescript: 40 | specifier: 5.8.3 41 | version: 5.8.3 42 | uvu: 43 | specifier: 0.5.4 44 | version: 0.5.4 45 | 46 | packages: 47 | 48 | '@esbuild/android-arm@0.15.18': 49 | resolution: {integrity: sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==} 50 | engines: {node: '>=12'} 51 | cpu: [arm] 52 | os: [android] 53 | 54 | '@esbuild/linux-loong64@0.14.54': 55 | resolution: {integrity: sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==} 56 | engines: {node: '>=12'} 57 | cpu: [loong64] 58 | os: [linux] 59 | 60 | '@esbuild/linux-loong64@0.15.18': 61 | resolution: {integrity: sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==} 62 | engines: {node: '>=12'} 63 | cpu: [loong64] 64 | os: [linux] 65 | 66 | '@jridgewell/gen-mapping@0.3.12': 67 | resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} 68 | 69 | '@jridgewell/resolve-uri@3.1.2': 70 | resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} 71 | engines: {node: '>=6.0.0'} 72 | 73 | '@jridgewell/source-map@0.3.10': 74 | resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==} 75 | 76 | '@jridgewell/sourcemap-codec@1.5.4': 77 | resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==} 78 | 79 | '@jridgewell/trace-mapping@0.3.29': 80 | resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} 81 | 82 | '@lukeed/csprng@1.1.0': 83 | resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} 84 | engines: {node: '>=8'} 85 | 86 | '@marais/prettier@0.0.4': 87 | resolution: {integrity: sha512-fcJgHALkAkmOyMEioqMaikXlUQLy9jj+SZjlI2AD9V0vEO1EjR3ZI5vz3y6A0Bz/PgskbyM9+F/A44850UWrhQ==} 88 | 89 | '@marais/tsconfig@0.0.4': 90 | resolution: {integrity: sha512-b6KCal22xP6E8wgl52rxdf8MXuffI4oJ9aTosucX4aVb97yl01wU0PzGF67oMA/i9KdzLa0rjQ0zVdZ+1pvVAg==} 91 | 92 | '@types/node@24.0.15': 93 | resolution: {integrity: sha512-oaeTSbCef7U/z7rDeJA138xpG3NuKc64/rZ2qmUFkFJmnMsAPaluIifqyWd8hSSMxyP9oie3dLAqYPblag9KgA==} 94 | 95 | acorn@8.15.0: 96 | resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} 97 | engines: {node: '>=0.4.0'} 98 | hasBin: true 99 | 100 | buffer-from@1.1.2: 101 | resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} 102 | 103 | bundt@2.0.0-next.5: 104 | resolution: {integrity: sha512-uoMMvvZUGRVyVbd0tls6ZU3bASc0lZt3b0iD3AE2J9sKgnsKJoWAWe4uUcCkla+Dx+T006ZERBvq0PY3iNuXlw==} 105 | engines: {node: '>=12'} 106 | hasBin: true 107 | 108 | commander@2.20.3: 109 | resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} 110 | 111 | dequal@2.0.3: 112 | resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} 113 | engines: {node: '>=6'} 114 | 115 | diff@5.2.0: 116 | resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} 117 | engines: {node: '>=0.3.1'} 118 | 119 | esbuild-android-64@0.14.54: 120 | resolution: {integrity: sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==} 121 | engines: {node: '>=12'} 122 | cpu: [x64] 123 | os: [android] 124 | 125 | esbuild-android-64@0.15.18: 126 | resolution: {integrity: sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==} 127 | engines: {node: '>=12'} 128 | cpu: [x64] 129 | os: [android] 130 | 131 | esbuild-android-arm64@0.14.54: 132 | resolution: {integrity: sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==} 133 | engines: {node: '>=12'} 134 | cpu: [arm64] 135 | os: [android] 136 | 137 | esbuild-android-arm64@0.15.18: 138 | resolution: {integrity: sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==} 139 | engines: {node: '>=12'} 140 | cpu: [arm64] 141 | os: [android] 142 | 143 | esbuild-darwin-64@0.14.54: 144 | resolution: {integrity: sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==} 145 | engines: {node: '>=12'} 146 | cpu: [x64] 147 | os: [darwin] 148 | 149 | esbuild-darwin-64@0.15.18: 150 | resolution: {integrity: sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==} 151 | engines: {node: '>=12'} 152 | cpu: [x64] 153 | os: [darwin] 154 | 155 | esbuild-darwin-arm64@0.14.54: 156 | resolution: {integrity: sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==} 157 | engines: {node: '>=12'} 158 | cpu: [arm64] 159 | os: [darwin] 160 | 161 | esbuild-darwin-arm64@0.15.18: 162 | resolution: {integrity: sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==} 163 | engines: {node: '>=12'} 164 | cpu: [arm64] 165 | os: [darwin] 166 | 167 | esbuild-freebsd-64@0.14.54: 168 | resolution: {integrity: sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==} 169 | engines: {node: '>=12'} 170 | cpu: [x64] 171 | os: [freebsd] 172 | 173 | esbuild-freebsd-64@0.15.18: 174 | resolution: {integrity: sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==} 175 | engines: {node: '>=12'} 176 | cpu: [x64] 177 | os: [freebsd] 178 | 179 | esbuild-freebsd-arm64@0.14.54: 180 | resolution: {integrity: sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==} 181 | engines: {node: '>=12'} 182 | cpu: [arm64] 183 | os: [freebsd] 184 | 185 | esbuild-freebsd-arm64@0.15.18: 186 | resolution: {integrity: sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==} 187 | engines: {node: '>=12'} 188 | cpu: [arm64] 189 | os: [freebsd] 190 | 191 | esbuild-linux-32@0.14.54: 192 | resolution: {integrity: sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==} 193 | engines: {node: '>=12'} 194 | cpu: [ia32] 195 | os: [linux] 196 | 197 | esbuild-linux-32@0.15.18: 198 | resolution: {integrity: sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==} 199 | engines: {node: '>=12'} 200 | cpu: [ia32] 201 | os: [linux] 202 | 203 | esbuild-linux-64@0.14.54: 204 | resolution: {integrity: sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==} 205 | engines: {node: '>=12'} 206 | cpu: [x64] 207 | os: [linux] 208 | 209 | esbuild-linux-64@0.15.18: 210 | resolution: {integrity: sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==} 211 | engines: {node: '>=12'} 212 | cpu: [x64] 213 | os: [linux] 214 | 215 | esbuild-linux-arm64@0.14.54: 216 | resolution: {integrity: sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==} 217 | engines: {node: '>=12'} 218 | cpu: [arm64] 219 | os: [linux] 220 | 221 | esbuild-linux-arm64@0.15.18: 222 | resolution: {integrity: sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==} 223 | engines: {node: '>=12'} 224 | cpu: [arm64] 225 | os: [linux] 226 | 227 | esbuild-linux-arm@0.14.54: 228 | resolution: {integrity: sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==} 229 | engines: {node: '>=12'} 230 | cpu: [arm] 231 | os: [linux] 232 | 233 | esbuild-linux-arm@0.15.18: 234 | resolution: {integrity: sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==} 235 | engines: {node: '>=12'} 236 | cpu: [arm] 237 | os: [linux] 238 | 239 | esbuild-linux-mips64le@0.14.54: 240 | resolution: {integrity: sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==} 241 | engines: {node: '>=12'} 242 | cpu: [mips64el] 243 | os: [linux] 244 | 245 | esbuild-linux-mips64le@0.15.18: 246 | resolution: {integrity: sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==} 247 | engines: {node: '>=12'} 248 | cpu: [mips64el] 249 | os: [linux] 250 | 251 | esbuild-linux-ppc64le@0.14.54: 252 | resolution: {integrity: sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==} 253 | engines: {node: '>=12'} 254 | cpu: [ppc64] 255 | os: [linux] 256 | 257 | esbuild-linux-ppc64le@0.15.18: 258 | resolution: {integrity: sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==} 259 | engines: {node: '>=12'} 260 | cpu: [ppc64] 261 | os: [linux] 262 | 263 | esbuild-linux-riscv64@0.14.54: 264 | resolution: {integrity: sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==} 265 | engines: {node: '>=12'} 266 | cpu: [riscv64] 267 | os: [linux] 268 | 269 | esbuild-linux-riscv64@0.15.18: 270 | resolution: {integrity: sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==} 271 | engines: {node: '>=12'} 272 | cpu: [riscv64] 273 | os: [linux] 274 | 275 | esbuild-linux-s390x@0.14.54: 276 | resolution: {integrity: sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==} 277 | engines: {node: '>=12'} 278 | cpu: [s390x] 279 | os: [linux] 280 | 281 | esbuild-linux-s390x@0.15.18: 282 | resolution: {integrity: sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==} 283 | engines: {node: '>=12'} 284 | cpu: [s390x] 285 | os: [linux] 286 | 287 | esbuild-netbsd-64@0.14.54: 288 | resolution: {integrity: sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==} 289 | engines: {node: '>=12'} 290 | cpu: [x64] 291 | os: [netbsd] 292 | 293 | esbuild-netbsd-64@0.15.18: 294 | resolution: {integrity: sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==} 295 | engines: {node: '>=12'} 296 | cpu: [x64] 297 | os: [netbsd] 298 | 299 | esbuild-openbsd-64@0.14.54: 300 | resolution: {integrity: sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==} 301 | engines: {node: '>=12'} 302 | cpu: [x64] 303 | os: [openbsd] 304 | 305 | esbuild-openbsd-64@0.15.18: 306 | resolution: {integrity: sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==} 307 | engines: {node: '>=12'} 308 | cpu: [x64] 309 | os: [openbsd] 310 | 311 | esbuild-sunos-64@0.14.54: 312 | resolution: {integrity: sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==} 313 | engines: {node: '>=12'} 314 | cpu: [x64] 315 | os: [sunos] 316 | 317 | esbuild-sunos-64@0.15.18: 318 | resolution: {integrity: sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==} 319 | engines: {node: '>=12'} 320 | cpu: [x64] 321 | os: [sunos] 322 | 323 | esbuild-windows-32@0.14.54: 324 | resolution: {integrity: sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==} 325 | engines: {node: '>=12'} 326 | cpu: [ia32] 327 | os: [win32] 328 | 329 | esbuild-windows-32@0.15.18: 330 | resolution: {integrity: sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==} 331 | engines: {node: '>=12'} 332 | cpu: [ia32] 333 | os: [win32] 334 | 335 | esbuild-windows-64@0.14.54: 336 | resolution: {integrity: sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==} 337 | engines: {node: '>=12'} 338 | cpu: [x64] 339 | os: [win32] 340 | 341 | esbuild-windows-64@0.15.18: 342 | resolution: {integrity: sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==} 343 | engines: {node: '>=12'} 344 | cpu: [x64] 345 | os: [win32] 346 | 347 | esbuild-windows-arm64@0.14.54: 348 | resolution: {integrity: sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==} 349 | engines: {node: '>=12'} 350 | cpu: [arm64] 351 | os: [win32] 352 | 353 | esbuild-windows-arm64@0.15.18: 354 | resolution: {integrity: sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==} 355 | engines: {node: '>=12'} 356 | cpu: [arm64] 357 | os: [win32] 358 | 359 | esbuild@0.14.54: 360 | resolution: {integrity: sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==} 361 | engines: {node: '>=12'} 362 | hasBin: true 363 | 364 | esbuild@0.15.18: 365 | resolution: {integrity: sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==} 366 | engines: {node: '>=12'} 367 | hasBin: true 368 | 369 | flattie@1.1.1: 370 | resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} 371 | engines: {node: '>=8'} 372 | 373 | kleur@4.1.5: 374 | resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} 375 | engines: {node: '>=6'} 376 | 377 | mri@1.2.0: 378 | resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} 379 | engines: {node: '>=4'} 380 | 381 | nanospy@1.0.0: 382 | resolution: {integrity: sha512-wvmmALNstRRhLhy7RV11NCRY2k1zxstImiju4VyyKNNRIKDVjyBtmEd/Q4G82/3dN4VSTe+0PRR3DUAASSbEEQ==} 383 | engines: {node: ^8.0.0 || ^10.0.0 || ^12.0.0 || ^14.0.0 || ^16.0.0 || ^18.0.0 || >=20.0.0} 384 | 385 | prettier@3.6.2: 386 | resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} 387 | engines: {node: '>=14'} 388 | hasBin: true 389 | 390 | rewrite-imports@2.0.3: 391 | resolution: {integrity: sha512-R7ICJEeP3y+d/q4C8YEJj9nRP0JyiSqG07uc0oQh8JvAe706dDFVL95GBZYCjADqmhArZWWjfM/5EcmVu4/B+g==} 392 | engines: {node: '>=6'} 393 | 394 | sade@1.8.1: 395 | resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} 396 | engines: {node: '>=6'} 397 | 398 | source-map-support@0.5.21: 399 | resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} 400 | 401 | source-map@0.6.1: 402 | resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} 403 | engines: {node: '>=0.10.0'} 404 | 405 | tctx@0.2.5: 406 | resolution: {integrity: sha512-OcvkrT1k+E2FNyJLJYuV66lOffCDtjxraggsrNKI0erFlF/cj/jq0bScxDY8bHldyMe97hKJNQwHfdKUMZmOqg==} 407 | 408 | terser@5.43.1: 409 | resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==} 410 | engines: {node: '>=10'} 411 | hasBin: true 412 | 413 | tsm@2.3.0: 414 | resolution: {integrity: sha512-++0HFnmmR+gMpDtKTnW3XJ4yv9kVGi20n+NfyQWB9qwJvTaIWY9kBmzek2YUQK5APTQ/1DTrXmm4QtFPmW9Rzw==} 415 | engines: {node: '>=12'} 416 | hasBin: true 417 | 418 | typescript@5.8.3: 419 | resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} 420 | engines: {node: '>=14.17'} 421 | hasBin: true 422 | 423 | undici-types@7.8.0: 424 | resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} 425 | 426 | uvu@0.5.4: 427 | resolution: {integrity: sha512-x1CyUjcP9VKaNPhjeB3FIc/jqgLsz2Q9LFhRzUTu/jnaaHILEGNuE0XckQonl8ISLcwyk9I2EZvWlYsQnwxqvQ==} 428 | engines: {node: '>=8'} 429 | hasBin: true 430 | 431 | snapshots: 432 | 433 | '@esbuild/android-arm@0.15.18': 434 | optional: true 435 | 436 | '@esbuild/linux-loong64@0.14.54': 437 | optional: true 438 | 439 | '@esbuild/linux-loong64@0.15.18': 440 | optional: true 441 | 442 | '@jridgewell/gen-mapping@0.3.12': 443 | dependencies: 444 | '@jridgewell/sourcemap-codec': 1.5.4 445 | '@jridgewell/trace-mapping': 0.3.29 446 | 447 | '@jridgewell/resolve-uri@3.1.2': {} 448 | 449 | '@jridgewell/source-map@0.3.10': 450 | dependencies: 451 | '@jridgewell/gen-mapping': 0.3.12 452 | '@jridgewell/trace-mapping': 0.3.29 453 | 454 | '@jridgewell/sourcemap-codec@1.5.4': {} 455 | 456 | '@jridgewell/trace-mapping@0.3.29': 457 | dependencies: 458 | '@jridgewell/resolve-uri': 3.1.2 459 | '@jridgewell/sourcemap-codec': 1.5.4 460 | 461 | '@lukeed/csprng@1.1.0': {} 462 | 463 | '@marais/prettier@0.0.4': {} 464 | 465 | '@marais/tsconfig@0.0.4': {} 466 | 467 | '@types/node@24.0.15': 468 | dependencies: 469 | undici-types: 7.8.0 470 | 471 | acorn@8.15.0: {} 472 | 473 | buffer-from@1.1.2: {} 474 | 475 | bundt@2.0.0-next.5: 476 | dependencies: 477 | esbuild: 0.14.54 478 | rewrite-imports: 2.0.3 479 | terser: 5.43.1 480 | 481 | commander@2.20.3: {} 482 | 483 | dequal@2.0.3: {} 484 | 485 | diff@5.2.0: {} 486 | 487 | esbuild-android-64@0.14.54: 488 | optional: true 489 | 490 | esbuild-android-64@0.15.18: 491 | optional: true 492 | 493 | esbuild-android-arm64@0.14.54: 494 | optional: true 495 | 496 | esbuild-android-arm64@0.15.18: 497 | optional: true 498 | 499 | esbuild-darwin-64@0.14.54: 500 | optional: true 501 | 502 | esbuild-darwin-64@0.15.18: 503 | optional: true 504 | 505 | esbuild-darwin-arm64@0.14.54: 506 | optional: true 507 | 508 | esbuild-darwin-arm64@0.15.18: 509 | optional: true 510 | 511 | esbuild-freebsd-64@0.14.54: 512 | optional: true 513 | 514 | esbuild-freebsd-64@0.15.18: 515 | optional: true 516 | 517 | esbuild-freebsd-arm64@0.14.54: 518 | optional: true 519 | 520 | esbuild-freebsd-arm64@0.15.18: 521 | optional: true 522 | 523 | esbuild-linux-32@0.14.54: 524 | optional: true 525 | 526 | esbuild-linux-32@0.15.18: 527 | optional: true 528 | 529 | esbuild-linux-64@0.14.54: 530 | optional: true 531 | 532 | esbuild-linux-64@0.15.18: 533 | optional: true 534 | 535 | esbuild-linux-arm64@0.14.54: 536 | optional: true 537 | 538 | esbuild-linux-arm64@0.15.18: 539 | optional: true 540 | 541 | esbuild-linux-arm@0.14.54: 542 | optional: true 543 | 544 | esbuild-linux-arm@0.15.18: 545 | optional: true 546 | 547 | esbuild-linux-mips64le@0.14.54: 548 | optional: true 549 | 550 | esbuild-linux-mips64le@0.15.18: 551 | optional: true 552 | 553 | esbuild-linux-ppc64le@0.14.54: 554 | optional: true 555 | 556 | esbuild-linux-ppc64le@0.15.18: 557 | optional: true 558 | 559 | esbuild-linux-riscv64@0.14.54: 560 | optional: true 561 | 562 | esbuild-linux-riscv64@0.15.18: 563 | optional: true 564 | 565 | esbuild-linux-s390x@0.14.54: 566 | optional: true 567 | 568 | esbuild-linux-s390x@0.15.18: 569 | optional: true 570 | 571 | esbuild-netbsd-64@0.14.54: 572 | optional: true 573 | 574 | esbuild-netbsd-64@0.15.18: 575 | optional: true 576 | 577 | esbuild-openbsd-64@0.14.54: 578 | optional: true 579 | 580 | esbuild-openbsd-64@0.15.18: 581 | optional: true 582 | 583 | esbuild-sunos-64@0.14.54: 584 | optional: true 585 | 586 | esbuild-sunos-64@0.15.18: 587 | optional: true 588 | 589 | esbuild-windows-32@0.14.54: 590 | optional: true 591 | 592 | esbuild-windows-32@0.15.18: 593 | optional: true 594 | 595 | esbuild-windows-64@0.14.54: 596 | optional: true 597 | 598 | esbuild-windows-64@0.15.18: 599 | optional: true 600 | 601 | esbuild-windows-arm64@0.14.54: 602 | optional: true 603 | 604 | esbuild-windows-arm64@0.15.18: 605 | optional: true 606 | 607 | esbuild@0.14.54: 608 | optionalDependencies: 609 | '@esbuild/linux-loong64': 0.14.54 610 | esbuild-android-64: 0.14.54 611 | esbuild-android-arm64: 0.14.54 612 | esbuild-darwin-64: 0.14.54 613 | esbuild-darwin-arm64: 0.14.54 614 | esbuild-freebsd-64: 0.14.54 615 | esbuild-freebsd-arm64: 0.14.54 616 | esbuild-linux-32: 0.14.54 617 | esbuild-linux-64: 0.14.54 618 | esbuild-linux-arm: 0.14.54 619 | esbuild-linux-arm64: 0.14.54 620 | esbuild-linux-mips64le: 0.14.54 621 | esbuild-linux-ppc64le: 0.14.54 622 | esbuild-linux-riscv64: 0.14.54 623 | esbuild-linux-s390x: 0.14.54 624 | esbuild-netbsd-64: 0.14.54 625 | esbuild-openbsd-64: 0.14.54 626 | esbuild-sunos-64: 0.14.54 627 | esbuild-windows-32: 0.14.54 628 | esbuild-windows-64: 0.14.54 629 | esbuild-windows-arm64: 0.14.54 630 | 631 | esbuild@0.15.18: 632 | optionalDependencies: 633 | '@esbuild/android-arm': 0.15.18 634 | '@esbuild/linux-loong64': 0.15.18 635 | esbuild-android-64: 0.15.18 636 | esbuild-android-arm64: 0.15.18 637 | esbuild-darwin-64: 0.15.18 638 | esbuild-darwin-arm64: 0.15.18 639 | esbuild-freebsd-64: 0.15.18 640 | esbuild-freebsd-arm64: 0.15.18 641 | esbuild-linux-32: 0.15.18 642 | esbuild-linux-64: 0.15.18 643 | esbuild-linux-arm: 0.15.18 644 | esbuild-linux-arm64: 0.15.18 645 | esbuild-linux-mips64le: 0.15.18 646 | esbuild-linux-ppc64le: 0.15.18 647 | esbuild-linux-riscv64: 0.15.18 648 | esbuild-linux-s390x: 0.15.18 649 | esbuild-netbsd-64: 0.15.18 650 | esbuild-openbsd-64: 0.15.18 651 | esbuild-sunos-64: 0.15.18 652 | esbuild-windows-32: 0.15.18 653 | esbuild-windows-64: 0.15.18 654 | esbuild-windows-arm64: 0.15.18 655 | 656 | flattie@1.1.1: {} 657 | 658 | kleur@4.1.5: {} 659 | 660 | mri@1.2.0: {} 661 | 662 | nanospy@1.0.0: {} 663 | 664 | prettier@3.6.2: {} 665 | 666 | rewrite-imports@2.0.3: {} 667 | 668 | sade@1.8.1: 669 | dependencies: 670 | mri: 1.2.0 671 | 672 | source-map-support@0.5.21: 673 | dependencies: 674 | buffer-from: 1.1.2 675 | source-map: 0.6.1 676 | 677 | source-map@0.6.1: {} 678 | 679 | tctx@0.2.5: 680 | dependencies: 681 | '@lukeed/csprng': 1.1.0 682 | 683 | terser@5.43.1: 684 | dependencies: 685 | '@jridgewell/source-map': 0.3.10 686 | acorn: 8.15.0 687 | commander: 2.20.3 688 | source-map-support: 0.5.21 689 | 690 | tsm@2.3.0: 691 | dependencies: 692 | esbuild: 0.15.18 693 | 694 | typescript@5.8.3: {} 695 | 696 | undici-types@7.8.0: {} 697 | 698 | uvu@0.5.4: 699 | dependencies: 700 | dequal: 2.0.3 701 | diff: 5.2.0 702 | kleur: 4.1.5 703 | sade: 1.8.1 704 | --------------------------------------------------------------------------------