├── .github └── workflows │ ├── pr.yml │ ├── release.yml │ └── snapshot.yml ├── .gitignore ├── LICENSE ├── Makefile ├── OSSMETADATA ├── README.md ├── RELEASE_PROCESS.md ├── eslint.config.mjs ├── package.json ├── src ├── common_tags.ts ├── config.ts ├── index.ts ├── logger │ └── logger.ts ├── meter │ ├── age_gauge.ts │ ├── counter.ts │ ├── dist_summary.ts │ ├── gauge.ts │ ├── id.ts │ ├── max_gauge.ts │ ├── meter.ts │ ├── monotonic_counter.ts │ ├── monotonic_counter_uint.ts │ ├── percentile_dist_summary.ts │ ├── percentile_timer.ts │ └── timer.ts ├── protocol_parser.ts ├── registry.ts └── writer │ ├── file_writer.ts │ ├── memory_writer.ts │ ├── new_writer.ts │ ├── noop_writer.ts │ ├── stderr_writer.ts │ ├── stdout_writer.ts │ ├── udp_writer.ts │ └── writer.ts ├── test ├── common_tags.test.ts ├── config.test.ts ├── logger │ └── logger.test.ts ├── meter │ ├── age_gauge.test.ts │ ├── counter.test.ts │ ├── dist_summary.test.ts │ ├── gauge.test.ts │ ├── id.test.ts │ ├── max_gauge.test.ts │ ├── monotonic_counter.test.ts │ ├── monotonic_counter_uint.test.ts │ ├── percentile_dist_summary.test.ts │ ├── percentile_timer.test.ts │ └── timer.test.ts ├── protocol_parser.test.ts ├── registry.test.ts └── writer │ ├── file_writer.test.ts │ ├── memory_writer.test.ts │ ├── noop_writer.test.ts │ └── udp_writer.test.ts └── tsconfig.json /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR Build 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: ["18", "20", "22"] 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Setup Node ${{ matrix.node-version }} 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | 19 | - name: Install Dependencies 20 | run: npm install 21 | 22 | - name: Compile TypeScript 23 | run: npm run build 24 | 25 | - name: Test with Coverage 26 | run: npm run test-with-coverage 27 | 28 | - name: Check Coverage 29 | run: npm run check-coverage 30 | 31 | - name: Lint 32 | run: npm run lint 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v[0-9]+.[0-9]+.[0-9]+ 7 | - v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+ 8 | 9 | jobs: 10 | build: 11 | if: ${{ github.repository == 'Netflix/spectator-js' }} 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Setup Node 18 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: "18" 20 | registry-url: "https://registry.npmjs.org" 21 | 22 | - name: Install Dependencies 23 | run: npm install 24 | 25 | - name: Compile TypeScript 26 | run: npm run build 27 | 28 | - name: Test with Coverage 29 | run: npm run test-with-coverage 30 | 31 | - name: Check Coverage 32 | run: npm run check-coverage 33 | 34 | - name: Lint 35 | run: npm run lint 36 | 37 | - name: Publish 38 | run: npm publish 39 | env: 40 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 41 | -------------------------------------------------------------------------------- /.github/workflows/snapshot.yml: -------------------------------------------------------------------------------- 1 | name: Snapshot 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | if: ${{ github.repository == 'Netflix/spectator-js' }} 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: ["18", "20", "22"] 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Setup Node ${{ matrix.node-version }} 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | 23 | - name: Install Dependencies 24 | run: npm install 25 | 26 | - name: Compile TypeScript 27 | run: npm run build 28 | 29 | - name: Test with Coverage 30 | run: npm run test-with-coverage 31 | 32 | - name: Check Coverage 33 | run: npm run check-coverage 34 | 35 | - name: Lint 36 | run: npm run lint 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | cjs/ 3 | esm/ 4 | coverage/ 5 | node_modules/ 6 | package-lock.json 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 Netflix Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ROOT := $(shell pwd) 2 | SYSTEM := $(shell uname -s) 3 | 4 | ## help: print this help message 5 | .PHONY: help 6 | help: 7 | @echo 'Usage:' 8 | @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' 9 | 10 | ## test: run tests with coverage enabled 11 | .PHONY: test 12 | test: 13 | npm run build-and-test 14 | -------------------------------------------------------------------------------- /OSSMETADATA: -------------------------------------------------------------------------------- 1 | osslifecycle=active 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Snapshot](https://github.com/Netflix/spectator-js/actions/workflows/snapshot.yml/badge.svg)](https://github.com/Netflix/spectator-js/actions/workflows/snapshot.yml) 2 | [![npm version](https://badge.fury.io/js/nflx-spectator.svg)](https://badge.fury.io/js/nflx-spectator) 3 | 4 | ## Spectator-js 5 | 6 | TypeScript thin-client metrics library compiled to JavaScript for use with [Atlas] and [SpectatorD]. 7 | 8 | Intended for use with [Node.js] applications. 9 | 10 | See the [Atlas Documentation] site for more details on `spectator-js`. 11 | 12 | [Atlas]: https://netflix.github.io/atlas-docs/overview/ 13 | [SpectatorD]: https://netflix.github.io/atlas-docs/spectator/agent/usage/ 14 | [Node.js]: https://nodejs.org 15 | [Atlas Documentation]: https://netflix.github.io/atlas-docs/spectator/lang/nodejs/usage/ 16 | 17 | ## Local Development 18 | 19 | Install a version of [Node.js] >= 18, possibly with [Homebrew] or [nvm]. 20 | 21 | ```shell 22 | npm run build-and-test 23 | ``` 24 | 25 | Followed the advice in [Supporting CommonJS and ESM with Typescript and Node] to produce both CJS and ESM output. 26 | 27 | [Homebrew]: https://brew.sh/ 28 | [nvm]: https://github.com/nvm-sh/nvm 29 | [Supporting CommonJS and ESM with Typescript and Node]: https://evertpot.com/universal-commonjs-esm-typescript-packages/ -------------------------------------------------------------------------------- /RELEASE_PROCESS.md: -------------------------------------------------------------------------------- 1 | * Bump the minor version for the addition of new features. 2 | * Bump the patch version for fixes to the existing features. 3 | * Update the version in `package.json`, this is the main library version. 4 | * Commit and push the version change. Wait for a green build. 5 | * Tag the repo and push the tag. 6 | * `npm publish` 7 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 2 | import tsParser from "@typescript-eslint/parser"; 3 | import path from "node:path"; 4 | import { fileURLToPath } from "node:url"; 5 | import js from "@eslint/js"; 6 | import { FlatCompat } from "@eslint/eslintrc"; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | const compat = new FlatCompat({ 11 | baseDirectory: __dirname, 12 | recommendedConfig: js.configs.recommended, 13 | allConfig: js.configs.all 14 | }); 15 | 16 | export default [...compat.extends("plugin:@typescript-eslint/recommended"), { 17 | plugins: { 18 | "@typescript-eslint": typescriptEslint, 19 | }, 20 | languageOptions: { 21 | parser: tsParser, 22 | }, 23 | rules: { 24 | "@typescript-eslint/no-explicit-any": "off", 25 | } 26 | }]; 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nflx-spectator", 3 | "version": "3.0.5", 4 | "license": "Apache-2.0", 5 | "homepage": "https://github.com/Netflix/spectator-js", 6 | "author": "Netflix Telemetry Engineering ", 7 | "type": "module", 8 | "main": "cjs/src/index.js", 9 | "types": "esm/src/index.d.ts", 10 | "exports": { 11 | ".": { 12 | "import": { 13 | "default": "./esm/src/index.js", 14 | "types": "./esm/src/index.d.ts" 15 | }, 16 | "require": { 17 | "default": "./cjs/src/index.js", 18 | "types": "./cjs/src/index.d.ts" 19 | } 20 | } 21 | }, 22 | "files": [ 23 | "cjs/package.json", 24 | "cjs/src/**/*", 25 | "esm/package.json", 26 | "esm/src/**/*" 27 | ], 28 | "engines": { 29 | "node": ">=18.0.0" 30 | }, 31 | "devDependencies": { 32 | "@eslint/eslintrc": "^3.3.0", 33 | "@eslint/js": "^9.9.1", 34 | "@types/chai": "^4.3.20", 35 | "@types/mocha": "^10.0.10", 36 | "@types/node": "^22.13.10", 37 | "@typescript-eslint/eslint-plugin": "^8.26.1", 38 | "@typescript-eslint/parser": "^8.26.1", 39 | "c8": "^10.1.3", 40 | "chai": "^5.2.0", 41 | "eslint": "^9.22.0", 42 | "mocha": "^10.8.2", 43 | "ts-node": "^10.9.2", 44 | "tsx": "^4.19.3", 45 | "typescript": "^5.8.2" 46 | }, 47 | "scripts": { 48 | "clean": "rm -rf cjs coverage esm", 49 | "build-cjs": "tsc --target es5 --module commonjs --outDir cjs; echo '{\"type\": \"commonjs\"}' > cjs/package.json", 50 | "build-esm": "tsc --target es2022 --module nodenext --outDir esm; echo '{\"type\": \"module\"}' > esm/package.json", 51 | "build": "npm run build-cjs; npm run build-esm", 52 | "build-and-test": "npm run build; npm run test", 53 | "lint": "eslint 'src/**/*.ts' 'test/**/*.ts'", 54 | "test": "mocha 'esm/test/**/*.test.js'", 55 | "test-with-coverage": "c8 mocha --reporter min 'esm/test/**/*.test.js'", 56 | "check-coverage": "c8 check-coverage --lines 90 mocha 'esm/test/**/*.test.js'" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/common_tags.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process"; 2 | 3 | function add_non_empty(tags: Record, tag: string, ...env_vars: string[]): void { 4 | for (const env_var of env_vars) { 5 | let value = process.env[env_var]; 6 | if (value == undefined) { 7 | continue; 8 | } 9 | value = value.toString().trim(); 10 | if (value.length > 0) { 11 | tags[tag] = value; 12 | break; 13 | } 14 | } 15 | } 16 | 17 | export function tags_from_env_vars(): Record { 18 | /** 19 | * Extract common infrastructure tags from the Netflix environment variables, which are 20 | * specific to a process and thus cannot be managed by a shared SpectatorD instance. 21 | */ 22 | const tags: Record = {}; 23 | add_non_empty(tags, "nf.container", "TITUS_CONTAINER_NAME"); 24 | add_non_empty(tags, "nf.process", "NETFLIX_PROCESS_NAME"); 25 | return tags; 26 | } 27 | 28 | export function validate_tags(tags: Record): Record { 29 | const valid_tags: Record = {}; 30 | 31 | for (const key in tags) { 32 | const val = tags[key]; 33 | if (key.length == 0 || val.length == 0) continue; 34 | valid_tags[key] = val; 35 | } 36 | 37 | return valid_tags; 38 | } 39 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import {tags_from_env_vars, validate_tags} from "./common_tags.js"; 2 | import {is_valid_output_location} from "./writer/new_writer.js"; 3 | import {get_logger, Logger} from "./logger/logger.js"; 4 | import {Tags} from "./meter/id.js" 5 | import process from "node:process"; 6 | 7 | 8 | export class Config { 9 | /** 10 | * Create a new configuration with the provided location, extra common tags, and logger. All fields 11 | * are optional. The extra common tags are added to every metric, on top of the common tags provided 12 | * by spectatord. 13 | * 14 | * Possible values for `location` are: 15 | * 16 | * * `none` - Configure a no-op writer that does nothing. Can be used to disable metrics collection. 17 | * * `memory` - Write metrics to memory. Useful for testing. 18 | * * `stderr` - Write metrics to standard error. 19 | * * `stdout` - Write metrics to standard output. 20 | * * `udp` - Write metrics to the default spectatord UDP port. This is the default value. 21 | * * `file:///path/to/file` - Write metrics to a file. 22 | * * `udp://host:port` - Write metrics to a UDP socket. 23 | * 24 | * The output location can be overridden by configuring an environment variable SPECTATOR_OUTPUT_LOCATION 25 | * with one of the values listed above. Overriding the output location may be useful for integration testing. 26 | * 27 | * Unix Domain Sockets are not supported in this library, because Node.js removed the `unix_dgram` package 28 | * from the standard library in 2011, as a part of portability concerns for Windows. 29 | * 30 | * https://github.com/nodejs/node/issues/29339 31 | * 32 | * There is a third-party `unix-dgram` library, but it contains C++ source code, which complicates the build, 33 | * and it introduces synchronous calls in the context of callbacks. We want this library to be as low-friction 34 | * as possible, so we will not adopt this package. If you need UDS support, use the C++, Go, or Python libraries 35 | * instead. 36 | * 37 | * https://github.com/bnoordhuis/node-unix-dgram 38 | */ 39 | 40 | location: string; 41 | extra_common_tags: Tags; 42 | logger: Logger; 43 | 44 | constructor(location: string = "udp", extra_common_tags: Tags = {}, logger: Logger = get_logger()) { 45 | this.location = this.calculate_location(location); 46 | this.extra_common_tags = this.calculate_extra_common_tags(extra_common_tags); 47 | this.logger = logger; 48 | } 49 | 50 | calculate_extra_common_tags(common_tags: Tags): Tags { 51 | const merged_tags: Record = validate_tags(common_tags); 52 | 53 | // merge common tags with env var tags; env vars take precedence 54 | const env_var_tags: Record = tags_from_env_vars(); 55 | for (const key in env_var_tags) { 56 | merged_tags[key] = env_var_tags[key]; 57 | } 58 | 59 | return merged_tags; 60 | } 61 | 62 | calculate_location(location: string): string { 63 | if (!is_valid_output_location(location)) { 64 | throw new Error(`spectatord output location is invalid: ${location}`); 65 | } 66 | 67 | const override = process.env.SPECTATOR_OUTPUT_LOCATION; 68 | if (override !== undefined) { 69 | if (!is_valid_output_location(override)) { 70 | throw new Error(`SPECTATOR_OUTPUT_LOCATION is invalid: ${override}`); 71 | } 72 | location = override; 73 | } 74 | 75 | return location; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {Config} from "./config.js"; 2 | export {Registry} from "./registry.js"; 3 | 4 | export {parse_protocol_line} from "./protocol_parser.js"; 5 | 6 | export {get_logger} from "./logger/logger.js" 7 | export type {Logger} from "./logger/logger.js" 8 | 9 | export {Meter} from "./meter/meter.js"; 10 | export {Id, tags_toString} from "./meter/id.js"; 11 | export type {Tags} from "./meter/id.js"; 12 | 13 | export {AgeGauge} from "./meter/age_gauge.js"; 14 | export {Counter} from "./meter/counter.js"; 15 | export {DistributionSummary} from "./meter/dist_summary.js"; 16 | export {Gauge} from "./meter/gauge.js"; 17 | export {MaxGauge} from "./meter/max_gauge.js"; 18 | export {MonotonicCounter} from "./meter/monotonic_counter.js"; 19 | export {MonotonicCounterUint} from "./meter/monotonic_counter_uint.js"; 20 | export {PercentileDistributionSummary} from "./meter/percentile_dist_summary.js"; 21 | export {PercentileTimer} from "./meter/percentile_timer.js"; 22 | export {Timer} from "./meter/timer.js"; 23 | 24 | export {new_writer} from "./writer/new_writer.js"; 25 | export type {WriterUnion} from "./writer/new_writer.js"; 26 | export {Writer} from "./writer/writer.js"; 27 | 28 | export {FileWriter} from "./writer/file_writer.js"; 29 | export {MemoryWriter, isMemoryWriter} from "./writer/memory_writer.js"; 30 | export {NoopWriter} from "./writer/noop_writer.js"; 31 | export {StderrWriter} from "./writer/stderr_writer.js"; 32 | export {StdoutWriter} from "./writer/stdout_writer.js"; 33 | export {UdpWriter} from "./writer/udp_writer.js"; 34 | -------------------------------------------------------------------------------- /src/logger/logger.ts: -------------------------------------------------------------------------------- 1 | const levels: Record = { 2 | trace: 10, 3 | debug: 20, 4 | info: 30, 5 | warn: 40, 6 | error: 50, 7 | fatal: 60 8 | }; 9 | 10 | export type Logger = Record boolean) | (() => void)>; 11 | 12 | export function get_logger(level_name?: string): Logger { 13 | let level_filter: number; 14 | if (level_name == undefined || !(level_name in levels)) { 15 | level_filter = levels.info; 16 | } else { 17 | level_filter = levels[level_name]; 18 | } 19 | 20 | const logger: Logger = { 21 | is_level_enabled: (name: string) => levels[name] >= level_filter 22 | }; 23 | 24 | for (const name in levels) { 25 | if (levels[name] < level_filter) { 26 | logger[name] = () => {}; 27 | } else { 28 | logger[name] = (...messages: string[]) => { 29 | messages[0] = name.toUpperCase() + ': ' + messages[0]; 30 | console.log.apply(null, messages); 31 | }; 32 | } 33 | } 34 | 35 | return logger; 36 | } 37 | -------------------------------------------------------------------------------- /src/meter/age_gauge.ts: -------------------------------------------------------------------------------- 1 | import {Id} from "./id.js"; 2 | import {Meter} from "./meter.js" 3 | import {new_writer, WriterUnion} from "../writer/new_writer.js"; 4 | 5 | export class AgeGauge extends Meter { 6 | /** 7 | * The value is the time in seconds since the epoch at which an event has successfully 8 | * occurred, or 0 to use the current time in epoch seconds. After an Age Gauge has been set, 9 | * it will continue reporting the number of seconds since the last time recorded, for as long 10 | * as the SpectatorD process runs. This meter type makes it easy to implement the Time Since 11 | * Last Success alerting pattern. 12 | */ 13 | 14 | constructor(id: Id, writer: WriterUnion = new_writer("none")) { 15 | super(id, writer, "A"); 16 | } 17 | 18 | now(): Promise { 19 | const line = `${this._meter_type_symbol}:${this._id.spectatord_id}:0` 20 | return this._writer.write(line); 21 | } 22 | 23 | set(seconds: number): Promise { 24 | const line = `${this._meter_type_symbol}:${this._id.spectatord_id}:${seconds}` 25 | return this._writer.write(line); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/meter/counter.ts: -------------------------------------------------------------------------------- 1 | import {Id} from "./id.js"; 2 | import {Meter} from "./meter.js" 3 | import {new_writer, WriterUnion} from "../writer/new_writer.js"; 4 | 5 | export class Counter extends Meter { 6 | /** 7 | * The value is the number of increments that have occurred since the last time it was 8 | * recorded. The value will be reported to the Atlas backend as a rate-per-second. 9 | */ 10 | 11 | constructor(id: Id, writer: WriterUnion = new_writer("none")) { 12 | super(id, writer, "c"); 13 | } 14 | 15 | increment(delta: number = 1): Promise { 16 | if (delta > 0) { 17 | const line = `${this._meter_type_symbol}:${this._id.spectatord_id}:${delta}` 18 | return this._writer.write(line); 19 | } else { 20 | return new Promise((resolve: (value: void | PromiseLike) => void): void => { 21 | resolve(); 22 | }); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/meter/dist_summary.ts: -------------------------------------------------------------------------------- 1 | import {Id} from "./id.js"; 2 | import {Meter} from "./meter.js" 3 | import {new_writer, WriterUnion} from "../writer/new_writer.js"; 4 | 5 | export class DistributionSummary extends Meter { 6 | /** 7 | * The value tracks the distribution of events. It is similar to a Timer, but more general, 8 | * because the size does not have to be a period of time. For example, it can be used to 9 | * measure the payload sizes of requests hitting a server or the number of records returned 10 | * from a query. 11 | */ 12 | 13 | constructor(id: Id, writer: WriterUnion = new_writer("none")) { 14 | super(id, writer, "d"); 15 | } 16 | 17 | record(amount: number): Promise { 18 | if (amount >= 0) { 19 | const line = `${this._meter_type_symbol}:${this._id.spectatord_id}:${amount}` 20 | return this._writer.write(line); 21 | } else { 22 | return new Promise((resolve: (value: void | PromiseLike) => void): void => { 23 | resolve(); 24 | }); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/meter/gauge.ts: -------------------------------------------------------------------------------- 1 | import {Id} from "./id.js"; 2 | import {Meter} from "./meter.js" 3 | import {new_writer, WriterUnion} from "../writer/new_writer.js"; 4 | 5 | export class Gauge extends Meter { 6 | /** 7 | * The value is a number that was sampled at a point in time. The default time-to-live (TTL) 8 | * for gauges is 900 seconds (15 minutes) - they will continue reporting the last value set for 9 | * this duration of time. An optional ttl_seconds may be set to control the lifespan of these 10 | * values. SpectatorD enforces a minimum TTL of 5 seconds. 11 | */ 12 | 13 | constructor(id: Id, writer: WriterUnion = new_writer("none"), ttl_seconds?: number) { 14 | let meter_type_symbol: string; 15 | if (ttl_seconds == undefined) { 16 | meter_type_symbol = "g" 17 | } else { 18 | meter_type_symbol = `g,${ttl_seconds}`; 19 | } 20 | super(id, writer, meter_type_symbol); 21 | } 22 | 23 | set(value: number): Promise { 24 | const line = `${this._meter_type_symbol}:${this._id.spectatord_id}:${value}` 25 | return this._writer.write(line); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/meter/id.ts: -------------------------------------------------------------------------------- 1 | import {get_logger, Logger} from "../logger/logger.js"; 2 | import {validate_tags} from "../common_tags.js"; 3 | 4 | export type Tags = Record; 5 | 6 | export function tags_toString(tags: Tags): string { 7 | let result: string = "{"; 8 | 9 | if (Object.entries(tags).length > 0) { 10 | Object.entries(tags).forEach(([k, v]: [string, string]): void => { 11 | result += `'${k}': '${v}', `; 12 | }); 13 | result = result.slice(0, -2); 14 | } 15 | 16 | return result + "}"; 17 | } 18 | 19 | export class Id { 20 | /** 21 | * The name and tags which uniquely identify a Meter instance. The tags are key-value pairs of 22 | * strings. This class should NOT be used directly. Instead, use the Registry.new_id() method, to 23 | * ensure that any extra common tags are properly applied to the Meter. 24 | */ 25 | 26 | private INVALID_CHARS: RegExp = new RegExp(/[^-._A-Za-z0-9~^]/g); 27 | 28 | private readonly _logger: Logger; 29 | private readonly _name: string; 30 | private readonly _tags: Tags; 31 | public spectatord_id: string; 32 | 33 | constructor(name: string, tags?: Tags, logger?: Logger) { 34 | // initialization order in this constructor matters, for logging and testing purposes 35 | if (logger == undefined) { 36 | this._logger = get_logger(); 37 | } else { 38 | this._logger = logger; 39 | } 40 | 41 | this._name = name; 42 | 43 | if (tags == undefined) { 44 | this._tags = {} 45 | } else { 46 | this._tags = this.validate_tags(tags); 47 | } 48 | 49 | this.spectatord_id = this.to_spectatord_id(this._name, this._tags); 50 | } 51 | 52 | private validate_tags(tags: Tags): Tags { 53 | const valid_tags: Record = validate_tags(tags); 54 | 55 | if (Object.entries(tags).length != Object.entries(valid_tags).length) { 56 | this._logger.warn(`Id(name=${this._name}, tags=${tags_toString(tags)}) is invalid due to tag keys or values which are ` + 57 | `zero-length strings; proceeding with truncated tags Id(name=${this._name}, tags=${tags_toString(valid_tags)})`); 58 | } 59 | 60 | return valid_tags; 61 | } 62 | 63 | private replace_invalid_chars(s: string): string { 64 | return s.replace(this.INVALID_CHARS, "_"); 65 | } 66 | 67 | private to_spectatord_id(name: string, tags?: Tags): string { 68 | if (tags == undefined) { 69 | tags = {}; 70 | } 71 | 72 | let result: string = this.replace_invalid_chars(name); 73 | 74 | const sorted_tags: {[k: string]: string} = Object.fromEntries(Object.entries(tags).sort( 75 | (a: [string, string], b: [string, string]): number => { 76 | const kSort: number = a[0].localeCompare(b[0]); 77 | if (kSort != 0) { 78 | return kSort; 79 | } else { 80 | return a[1].localeCompare(b[1]); 81 | } 82 | } 83 | )); 84 | 85 | Object.entries(sorted_tags).forEach(([k, v]: [string, string]): void => { 86 | k = this.replace_invalid_chars(k); 87 | v = this.replace_invalid_chars(v); 88 | result += `,${k}=${v}`; 89 | }); 90 | 91 | return result; 92 | } 93 | 94 | name(): string { 95 | return this._name; 96 | } 97 | 98 | tags(): Tags { 99 | return structuredClone(this._tags); 100 | } 101 | 102 | with_tag(k: string, v: string): Id { 103 | const new_tags: Tags = structuredClone(this._tags); 104 | new_tags[k] = v; 105 | return new Id(this._name, new_tags); 106 | } 107 | 108 | with_tags(tags: Tags): Id { 109 | if (Object.keys(tags).length == 0) { 110 | return this; 111 | } 112 | const new_tags = {...structuredClone(this._tags), ...tags}; 113 | return new Id(this._name, new_tags); 114 | } 115 | 116 | toString(): string { 117 | return `Id(name=${this._name}, tags=${tags_toString(this._tags)})`; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/meter/max_gauge.ts: -------------------------------------------------------------------------------- 1 | import {Id} from "./id.js"; 2 | import {Meter} from "./meter.js" 3 | import {new_writer, WriterUnion} from "../writer/new_writer.js"; 4 | 5 | export class MaxGauge extends Meter { 6 | /** 7 | * The value is a number that was sampled at a point in time, but it is reported as a maximum 8 | * gauge value to the backend. 9 | */ 10 | 11 | constructor(id: Id, writer: WriterUnion = new_writer("none")) { 12 | super(id, writer, "m"); 13 | } 14 | 15 | set(value: number): Promise { 16 | const line = `${this._meter_type_symbol}:${this._id.spectatord_id}:${value}` 17 | return this._writer.write(line); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/meter/meter.ts: -------------------------------------------------------------------------------- 1 | import {Id} from "./id.js"; 2 | import {WriterUnion} from "../writer/new_writer.js"; 3 | 4 | export abstract class Meter { 5 | protected _id: Id; 6 | protected _meter_type_symbol: string; 7 | protected readonly _writer: WriterUnion; 8 | 9 | protected constructor(id: Id, writer: WriterUnion, meter_type_symbol: string) { 10 | this._id = id; 11 | this._meter_type_symbol = meter_type_symbol; 12 | this._writer = writer; 13 | } 14 | 15 | public writer(): WriterUnion { 16 | return this._writer; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/meter/monotonic_counter.ts: -------------------------------------------------------------------------------- 1 | import {Id} from "./id.js"; 2 | import {Meter} from "./meter.js" 3 | import {new_writer, WriterUnion} from "../writer/new_writer.js"; 4 | 5 | export class MonotonicCounter extends Meter { 6 | /** 7 | * The value is a monotonically increasing number. A minimum of two samples must be received 8 | * in order for SpectatorD to calculate a delta value and report it to the backend. 9 | */ 10 | 11 | constructor(id: Id, writer: WriterUnion = new_writer("none")) { 12 | super(id, writer, "C"); 13 | } 14 | 15 | set(amount: number): Promise { 16 | const line = `${this._meter_type_symbol}:${this._id.spectatord_id}:${amount}` 17 | return this._writer.write(line); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/meter/monotonic_counter_uint.ts: -------------------------------------------------------------------------------- 1 | import {Id} from "./id.js"; 2 | import {Meter} from "./meter.js" 3 | import {new_writer, WriterUnion} from "../writer/new_writer.js"; 4 | 5 | export class MonotonicCounterUint extends Meter { 6 | /** 7 | * The value is a monotonically increasing number of an uint64 data type. These kinds of 8 | * values are commonly seen in networking metrics, such as bytes-per-second. A minimum of two 9 | * samples must be received in order for SpectatorD to calculate a delta value and report it to 10 | * the backend. 11 | * 12 | * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt/asUintN 13 | */ 14 | 15 | constructor(id: Id, writer: WriterUnion = new_writer("none")) { 16 | super(id, writer, "U"); 17 | } 18 | 19 | set(amount: bigint): Promise { 20 | const line = `${this._meter_type_symbol}:${this._id.spectatord_id}:${BigInt.asUintN(64, amount)}` 21 | return this._writer.write(line); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/meter/percentile_dist_summary.ts: -------------------------------------------------------------------------------- 1 | import {Id} from "./id.js"; 2 | import {Meter} from "./meter.js" 3 | import {new_writer, WriterUnion} from "../writer/new_writer.js"; 4 | 5 | export class PercentileDistributionSummary extends Meter { 6 | /** 7 | * The value tracks the distribution of events. It is similar to a Timer, but more general, 8 | * because the size does not have to be a period of time. For example, it can be used to 9 | * measure the payload sizes of requests hitting a server or the number of records returned 10 | * from a query. 11 | * 12 | * In order to maintain the data distribution, Percentile Distribution Summaries have a higher 13 | * storage cost, with a worst-case of up to 300X that of a standard Distribution Summary. Be 14 | * diligent about any additional dimensions added to Percentile Distribution Summaries and ensure 15 | * that they have a small bounded cardinality. 16 | */ 17 | 18 | constructor(id: Id, writer: WriterUnion = new_writer("none")) { 19 | super(id, writer, "D"); 20 | } 21 | 22 | record(amount: number): Promise { 23 | if (amount >= 0) { 24 | const line = `${this._meter_type_symbol}:${this._id.spectatord_id}:${amount}` 25 | return this._writer.write(line); 26 | } else { 27 | return new Promise((resolve: (value: void | PromiseLike) => void): void => { 28 | resolve(); 29 | }); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/meter/percentile_timer.ts: -------------------------------------------------------------------------------- 1 | import {Id} from "./id.js"; 2 | import {Meter} from "./meter.js" 3 | import {new_writer, WriterUnion} from "../writer/new_writer.js"; 4 | 5 | export class PercentileTimer extends Meter { 6 | /** 7 | * The value is the number of seconds that have elapsed for an event. A stopwatch method 8 | * is available, which provides a context manager that can be used to automate recording the 9 | * timing for a block of code using the `with` statement. 10 | * 11 | * In order to maintain the data distribution, Percentile Timers have a higher storage cost, 12 | * with a worst-case of up to 300X that of a standard Timer. Be diligent about any additional 13 | * dimensions added to Percentile Timers and ensure that they have a small bounded cardinality. 14 | */ 15 | 16 | constructor(id: Id, writer: WriterUnion = new_writer("none")) { 17 | super(id, writer, "T"); 18 | } 19 | 20 | /** 21 | * @param {number|number[]} seconds 22 | * Number of seconds, which may be fractional, or an array of two numbers [seconds, nanoseconds], 23 | * which is the return value from process.hrtime(), and serves as a convenient means of recording 24 | * latency durations. 25 | * 26 | * start = process.hrtime(); 27 | * // do work 28 | * registry.pct_timer("eventLatency").record(process.hrtime(start)); 29 | * 30 | */ 31 | record(seconds: number | number[]): Promise { 32 | let elapsed: number; 33 | 34 | if (seconds instanceof Array) { 35 | elapsed = seconds[0] + (seconds[1] / 1e9); 36 | } else { 37 | elapsed = seconds; 38 | } 39 | 40 | if (elapsed >= 0) { 41 | const line = `${this._meter_type_symbol}:${this._id.spectatord_id}:${elapsed}`; 42 | return this._writer.write(line); 43 | } else { 44 | return new Promise((resolve: (value: void | PromiseLike) => void): void => { 45 | resolve(); 46 | }); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/meter/timer.ts: -------------------------------------------------------------------------------- 1 | import {Id} from "./id.js"; 2 | import {Meter} from "./meter.js" 3 | import {new_writer, WriterUnion} from "../writer/new_writer.js"; 4 | 5 | export class Timer extends Meter { 6 | /** 7 | * The value is the number of seconds that have elapsed for an event. 8 | */ 9 | 10 | constructor(id: Id, writer: WriterUnion = new_writer("none")) { 11 | super(id, writer, "t"); 12 | } 13 | 14 | /** 15 | * @param {number|number[]} seconds 16 | * Number of seconds, which may be fractional, or an array of two numbers [seconds, nanoseconds], 17 | * which is the return value from process.hrtime(), and serves as a convenient means of recording 18 | * latency durations. 19 | * 20 | * start = process.hrtime(); 21 | * // do work 22 | * registry.timer("eventLatency").record(process.hrtime(start)); 23 | * 24 | */ 25 | record(seconds: number | number[]): Promise { 26 | let elapsed: number; 27 | 28 | if (seconds instanceof Array) { 29 | elapsed = seconds[0] + (seconds[1] / 1e9); 30 | } else { 31 | elapsed = seconds; 32 | } 33 | 34 | if (elapsed >= 0) { 35 | const line = `${this._meter_type_symbol}:${this._id.spectatord_id}:${elapsed}`; 36 | return this._writer.write(line); 37 | } else { 38 | return new Promise((resolve: (value: void | PromiseLike) => void): void => { 39 | resolve(); 40 | }); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/protocol_parser.ts: -------------------------------------------------------------------------------- 1 | import {Id} from "./meter/id.js"; 2 | 3 | export function parse_protocol_line(line: string): [string, Id, string] { 4 | /** 5 | * Parse a SpectatorD protocol line into component parts. Utility exposed for testing. 6 | */ 7 | const [symbol_segment, id_segment, value] = line.split(":"); 8 | if (symbol_segment == undefined || id_segment == undefined || value == undefined) { 9 | throw Error(`invalid protocol line: ${line}`) 10 | } 11 | 12 | // remove optional parts, such as gauge ttls 13 | const symbol = symbol_segment.split(",")[0]; 14 | const id = id_segment.split(","); 15 | const name = id[0]; 16 | 17 | const tags: Record = {}; 18 | if (id.length > 1) { 19 | for (const tag of id.slice(1)) { 20 | const [key, val] = tag.split("="); 21 | tags[key] = val; 22 | } 23 | } 24 | 25 | return [symbol, new Id(name, tags), value]; 26 | } 27 | -------------------------------------------------------------------------------- /src/registry.ts: -------------------------------------------------------------------------------- 1 | import {Config} from "./config.js"; 2 | import {Logger} from "./logger/logger.js"; 3 | import {Id, Tags, tags_toString} from "./meter/id.js"; 4 | 5 | import {AgeGauge} from "./meter/age_gauge.js"; 6 | import {Counter} from "./meter/counter.js"; 7 | import {DistributionSummary} from "./meter/dist_summary.js"; 8 | import {Gauge} from "./meter/gauge.js"; 9 | import {MaxGauge} from "./meter/max_gauge.js"; 10 | import {MonotonicCounter} from "./meter/monotonic_counter.js"; 11 | import {MonotonicCounterUint} from "./meter/monotonic_counter_uint.js"; 12 | import {PercentileDistributionSummary} from "./meter/percentile_dist_summary.js"; 13 | import {PercentileTimer} from "./meter/percentile_timer.js"; 14 | import {Timer} from "./meter/timer.js"; 15 | import {new_writer, WriterUnion} from "./writer/new_writer.js"; 16 | 17 | 18 | export class Registry { 19 | /** 20 | * Registry is the main entry point for interacting with the Spectator library. 21 | */ 22 | 23 | public logger: Logger; 24 | 25 | private _config: Config; 26 | private readonly _writer: WriterUnion; 27 | 28 | constructor(config: Config = new Config()) { 29 | this._config = config; 30 | this.logger = config.logger; 31 | this._writer = new_writer(this._config.location, this.logger); 32 | this.logger.debug(`Create Registry with extra_common_tags=${tags_toString(this._config.extra_common_tags)}`); 33 | } 34 | 35 | writer(): WriterUnion { 36 | return this._writer; 37 | } 38 | 39 | close(): Promise { 40 | try { 41 | this.logger.debug("Close Registry Writer"); 42 | return this._writer.close(); 43 | } catch (error) { 44 | if (error instanceof Error) { 45 | this.logger.error(`Error closing Registry Writer: ${error.message}`); 46 | } 47 | return new Promise((_: (value: void | PromiseLike) => void, reject: (reason?: any) => void): void => { 48 | reject(error); 49 | }); 50 | } 51 | } 52 | 53 | new_id(name: string, tags: Tags = {}): Id { 54 | /** 55 | * Create a new MeterId, which applies any configured extra common tags, 56 | * and can be used as an input to the *_with_id Registry methods. 57 | */ 58 | let new_meter_id: Id = new Id(name, tags); 59 | 60 | if (Object.keys(this._config.extra_common_tags).length > 0) { 61 | new_meter_id = new_meter_id.with_tags(this._config.extra_common_tags); 62 | } 63 | 64 | return new_meter_id; 65 | } 66 | 67 | age_gauge(name: string, tags: Tags = {}): AgeGauge { 68 | return new AgeGauge(this.new_id(name, tags), this._writer); 69 | } 70 | 71 | age_gauge_with_id(id: Id): AgeGauge { 72 | return new AgeGauge(id, this._writer); 73 | } 74 | 75 | counter(name: string, tags: Tags = {}): Counter { 76 | return new Counter(this.new_id(name, tags), this._writer); 77 | } 78 | 79 | counter_with_id(id: Id): Counter { 80 | return new Counter(id, this._writer); 81 | } 82 | 83 | distribution_summary(name: string, tags: Tags = {}): DistributionSummary { 84 | return new DistributionSummary(this.new_id(name, tags), this._writer); 85 | } 86 | 87 | distribution_summary_with_id(id: Id): DistributionSummary { 88 | return new DistributionSummary(id, this._writer); 89 | } 90 | 91 | gauge(name: string, tags: Tags = {}, ttl_seconds?: number): Gauge { 92 | return new Gauge(this.new_id(name, tags), this._writer, ttl_seconds); 93 | } 94 | 95 | gauge_with_id(id: Id, ttl_seconds?: number): Gauge { 96 | return new Gauge(id, this._writer, ttl_seconds); 97 | } 98 | 99 | max_gauge(name: string, tags: Tags = {}): MaxGauge { 100 | return new MaxGauge(this.new_id(name, tags), this._writer); 101 | } 102 | 103 | max_gauge_with_id(id: Id): MaxGauge { 104 | return new MaxGauge(id, this._writer); 105 | } 106 | 107 | monotonic_counter(name: string, tags: Tags = {}): MonotonicCounter { 108 | return new MonotonicCounter(this.new_id(name, tags), this._writer); 109 | } 110 | 111 | monotonic_counter_with_id(id: Id): MonotonicCounter { 112 | return new MonotonicCounter(id, this._writer); 113 | } 114 | 115 | monotonic_counter_uint(name: string, tags: Tags = {}): MonotonicCounterUint { 116 | return new MonotonicCounterUint(this.new_id(name, tags), this._writer); 117 | } 118 | 119 | monotonic_counter_uint_with_id(id: Id): MonotonicCounterUint { 120 | return new MonotonicCounterUint(id, this._writer); 121 | } 122 | 123 | pct_distribution_summary(name: string, tags: Tags = {}): PercentileDistributionSummary { 124 | return new PercentileDistributionSummary(this.new_id(name, tags), this._writer); 125 | } 126 | 127 | pct_distribution_summary_with_id(id: Id): PercentileDistributionSummary { 128 | return new PercentileDistributionSummary(id, this._writer); 129 | } 130 | 131 | pct_timer(name: string, tags: Tags = {}): PercentileTimer { 132 | return new PercentileTimer(this.new_id(name, tags), this._writer); 133 | } 134 | 135 | pct_timer_with_id(id: Id): PercentileTimer { 136 | return new PercentileTimer(id, this._writer); 137 | } 138 | 139 | timer(name: string, tags: Tags = {}): Timer { 140 | return new Timer(this.new_id(name, tags), this._writer); 141 | } 142 | 143 | timer_with_id(id: Id): Timer { 144 | return new Timer(id, this._writer); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/writer/file_writer.ts: -------------------------------------------------------------------------------- 1 | import {Writer} from "./writer.js"; 2 | import {get_logger, Logger} from "../logger/logger.js"; 3 | import fs from "node:fs/promises"; 4 | import {fileURLToPath} from "node:url"; 5 | 6 | export class FileWriter extends Writer { 7 | /** 8 | * Writer that outputs data to a file descriptor, which can be stdout, stderr, or a regular file. 9 | */ 10 | 11 | private readonly _location: string; 12 | 13 | constructor(location: string, logger: Logger = get_logger()) { 14 | super(logger); 15 | // convert from URL string to PathLike string 16 | this._location = fileURLToPath(location); 17 | this._logger.debug(`initialize FileWriter to ${location}`); 18 | } 19 | 20 | write(line: string): Promise { 21 | try { 22 | this._logger.debug(`write line=${line}`); 23 | return fs.appendFile(this._location, line + "\n"); 24 | } catch (error) { 25 | return new Promise((_: (value: void | PromiseLike) => void, reject: (reason?: any) => void): void => { 26 | if (error instanceof Error) { 27 | this._logger.error(`failed to write line=${line}: ${error.message}`); 28 | } 29 | reject(error); 30 | }); 31 | } 32 | } 33 | 34 | close(): Promise { 35 | return new Promise((resolve: (value: void | PromiseLike) => void): void => { 36 | resolve(); 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/writer/memory_writer.ts: -------------------------------------------------------------------------------- 1 | import {Writer} from "./writer.js"; 2 | import {get_logger, Logger} from "../logger/logger.js"; 3 | import {WriterUnion} from "./new_writer.js"; 4 | 5 | export function isMemoryWriter(writer: WriterUnion): writer is MemoryWriter { 6 | return (writer as MemoryWriter).is_empty !== undefined; 7 | } 8 | 9 | export class MemoryWriter extends Writer { 10 | /** 11 | * Writer that stores lines in a list, to support unit testing. 12 | */ 13 | 14 | private _messages: string[]; 15 | 16 | constructor(logger: Logger = get_logger()) { 17 | super(logger); 18 | this._logger.debug("initialize MemoryWriter"); 19 | this._messages = []; 20 | } 21 | 22 | write(line: string): Promise { 23 | return new Promise((resolve: (value: void | PromiseLike) => void): void => { 24 | this._logger.debug(`write line=${line}`); 25 | this._messages.push(line); 26 | resolve(); 27 | }); 28 | } 29 | 30 | close(): Promise { 31 | return new Promise((resolve: (value: void | PromiseLike) => void): void => { 32 | this._messages = []; 33 | resolve(); 34 | }); 35 | } 36 | 37 | get(): string[] { 38 | return this._messages; 39 | } 40 | 41 | clear(): void { 42 | this._messages = []; 43 | } 44 | 45 | is_empty(): boolean { 46 | return this._messages.length == 0; 47 | } 48 | 49 | last_line(): string { 50 | return this._messages[this._messages.length - 1]; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/writer/new_writer.ts: -------------------------------------------------------------------------------- 1 | import {Logger} from "../logger/logger.js"; 2 | import {NoopWriter} from "./noop_writer.js"; 3 | import {MemoryWriter} from "./memory_writer.js"; 4 | import {FileWriter} from "./file_writer.js"; 5 | import {UdpWriter} from "./udp_writer.js"; 6 | import {URL} from "node:url"; 7 | import {StderrWriter} from "./stderr_writer.js"; 8 | import {StdoutWriter} from "./stdout_writer.js"; 9 | 10 | export type WriterUnion = FileWriter | MemoryWriter | NoopWriter | StderrWriter | StdoutWriter | UdpWriter; 11 | 12 | export function is_valid_output_location(location: string): boolean { 13 | return ["none", "memory", "stderr", "stdout", "udp"].includes(location) || 14 | location.startsWith("file://") || 15 | location.startsWith("udp://"); 16 | } 17 | 18 | export function new_writer(location: string, logger?: Logger): WriterUnion { 19 | /** 20 | * Create a new Writer based on an output location. 21 | */ 22 | 23 | let writer: WriterUnion; 24 | 25 | if (location == "none") { 26 | writer = logger == undefined ? new NoopWriter() : new NoopWriter(logger); 27 | } else if (location == "memory") { 28 | writer = logger == undefined ? new MemoryWriter() : new MemoryWriter(logger); 29 | } else if (location == "stderr") { 30 | writer = logger == undefined ? new StderrWriter() : new StderrWriter(logger); 31 | } else if (location == "stdout") { 32 | writer = logger == undefined ? new StdoutWriter() : new StdoutWriter(logger); 33 | } else if (location == "udp") { 34 | location = "udp://127.0.0.1:1234" 35 | const parsed: URL = new URL(location) 36 | if (logger == undefined) { 37 | writer = new UdpWriter(location, parsed.hostname, Number(parsed.port)); 38 | } else { 39 | writer = new UdpWriter(location, parsed.hostname, Number(parsed.port), logger); 40 | } 41 | } else if (location.startsWith("file://")) { 42 | writer = logger == undefined ? new FileWriter(location) : new FileWriter(location, logger); 43 | } else if (location.startsWith("udp://")) { 44 | const parsed: URL = new URL(location) 45 | // convert IPv6 loop-back address from [::1] to ::1, so it works with the socket api 46 | const hostname: string = parsed.hostname.replace("[::1]", "::1") 47 | if (logger == undefined) { 48 | writer = new UdpWriter(location, hostname, Number(parsed.port)); 49 | } else { 50 | writer = new UdpWriter(location, hostname, Number(parsed.port), logger); 51 | } 52 | } else { 53 | throw new Error(`unsupported Writer location: ${location}`); 54 | } 55 | 56 | return writer; 57 | } 58 | -------------------------------------------------------------------------------- /src/writer/noop_writer.ts: -------------------------------------------------------------------------------- 1 | import {Writer} from "./writer.js"; 2 | import {get_logger, Logger} from "../logger/logger.js"; 3 | 4 | export class NoopWriter extends Writer { 5 | /** 6 | * Writer that does nothing. Used to disable output. 7 | */ 8 | 9 | constructor(logger: Logger = get_logger()) { 10 | super(logger); 11 | this._logger.debug("initialize NoopWriter"); 12 | } 13 | 14 | write(line: string): Promise { 15 | return new Promise((resolve: (value: void | PromiseLike) => void): void => { 16 | this._logger.debug(`write line=${line}`); 17 | resolve(); 18 | }); 19 | } 20 | 21 | close(): Promise { 22 | return new Promise((resolve: (value: void | PromiseLike) => void): void => { 23 | resolve(); 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/writer/stderr_writer.ts: -------------------------------------------------------------------------------- 1 | import {Writer} from "./writer.js"; 2 | import {get_logger, Logger} from "../logger/logger.js"; 3 | 4 | export class StderrWriter extends Writer { 5 | /** 6 | * Writer that outputs data to stderr. 7 | */ 8 | 9 | constructor(logger: Logger = get_logger()) { 10 | super(logger); 11 | this._logger.debug("initialize StderrWriter"); 12 | } 13 | 14 | write(line: string): Promise { 15 | return new Promise((resolve: (value: void | PromiseLike) => void): void => { 16 | this._logger.debug(`write line=${line}`); 17 | process.stderr.write(line); 18 | resolve(); 19 | }); 20 | } 21 | 22 | close(): Promise { 23 | return new Promise((resolve: (value: void | PromiseLike) => void): void => { 24 | resolve(); 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/writer/stdout_writer.ts: -------------------------------------------------------------------------------- 1 | import {Writer} from "./writer.js"; 2 | import {get_logger, Logger} from "../logger/logger.js"; 3 | 4 | export class StdoutWriter extends Writer { 5 | /** 6 | * Writer that outputs data to stdout. 7 | */ 8 | 9 | constructor(logger: Logger = get_logger()) { 10 | super(logger); 11 | this._logger.debug("initialize StdoutWriter"); 12 | } 13 | 14 | write(line: string): Promise { 15 | return new Promise((resolve: (value: void | PromiseLike) => void): void => { 16 | this._logger.debug(`write line=${line}`); 17 | process.stdout.write(line); 18 | resolve(); 19 | }); 20 | } 21 | 22 | close(): Promise { 23 | return new Promise((resolve: (value: void | PromiseLike) => void): void => { 24 | resolve(); 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/writer/udp_writer.ts: -------------------------------------------------------------------------------- 1 | import {Writer} from "./writer.js"; 2 | import {get_logger, Logger} from "../logger/logger.js"; 3 | import {createSocket, Socket, SocketType} from "node:dgram"; 4 | import {isIPv6} from "node:net"; 5 | 6 | export class UdpWriter extends Writer { 7 | /** 8 | * Writer that outputs data to a UDP socket. 9 | */ 10 | 11 | private readonly _address: string; 12 | private readonly _port: number; 13 | private readonly _family: SocketType; 14 | private _socket: Socket; 15 | 16 | constructor(location: string, address: string, port: number, logger: Logger = get_logger()) { 17 | super(logger); 18 | this._logger.debug(`initialize UdpWriter to ${location}`); 19 | this._address = address; 20 | this._port = port; 21 | 22 | if (isIPv6(this._address)) { 23 | this._family = "udp6"; 24 | } else { 25 | // IPv4 addresses, and anything that does not appear to be an IPv4 or IPv6 address (i.e. hostnames) 26 | this._family = "udp4"; 27 | } 28 | 29 | this._socket = createSocket(this._family); 30 | } 31 | 32 | write(line: string): Promise { 33 | return new Promise((resolve: (value: void | PromiseLike) => void, reject: (reason?: any) => void): void => { 34 | this._logger.debug(`write line=${line}`); 35 | this._socket.send(line, this._port, this._address, (err: Error | null): void => { 36 | if (err) { 37 | this._logger.error(`failed to write line=${line}: ${err.message}`); 38 | reject(err); 39 | } else { 40 | resolve(); 41 | } 42 | }) 43 | }); 44 | } 45 | 46 | close(): Promise { 47 | return new Promise((resolve: (value: void | PromiseLike) => void): void => { 48 | this._socket.close((): void => { 49 | resolve(); 50 | }); 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/writer/writer.ts: -------------------------------------------------------------------------------- 1 | import {Logger} from "../logger/logger.js"; 2 | 3 | export abstract class Writer { 4 | protected _logger: Logger; 5 | 6 | protected constructor(logger: Logger) { 7 | this._logger = logger; 8 | } 9 | 10 | abstract write(line: string): Promise; 11 | abstract close(): Promise; 12 | } 13 | -------------------------------------------------------------------------------- /test/common_tags.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {tags_from_env_vars, validate_tags} from "../src/common_tags.js"; 3 | import process from "node:process"; 4 | import {describe, it} from "node:test"; 5 | 6 | describe("Common Tags Tests", (): void => { 7 | function all_expected_tags(): Record { 8 | return { 9 | "nf.container": "main", 10 | "nf.process": "nodejs", 11 | }; 12 | } 13 | 14 | function setup_environment(): void { 15 | process.env.NETFLIX_PROCESS_NAME = "nodejs"; 16 | process.env.TITUS_CONTAINER_NAME = "main"; 17 | } 18 | 19 | function clear_environment(): void { 20 | const keys = ["NETFLIX_PROCESS_NAME", "TITUS_CONTAINER_NAME"]; 21 | 22 | for (const key of keys) { 23 | delete process.env[key]; 24 | } 25 | } 26 | 27 | it("get tags from env vars", (): void => { 28 | setup_environment(); 29 | assert.deepEqual(all_expected_tags(), tags_from_env_vars()); 30 | clear_environment(); 31 | }); 32 | 33 | it("get tags from env vars with empty vars ignored", (): void => { 34 | setup_environment(); 35 | process.env.TITUS_CONTAINER_NAME = ""; 36 | 37 | const expected_tags: Record = all_expected_tags(); 38 | delete expected_tags["nf.container"]; 39 | assert.deepEqual(expected_tags, tags_from_env_vars()); 40 | 41 | clear_environment(); 42 | }); 43 | 44 | it("get tags from env vars with missing vars ignored", (): void => { 45 | setup_environment(); 46 | delete process.env.TITUS_CONTAINER_NAME; 47 | 48 | const expected_tags: Record = all_expected_tags(); 49 | delete expected_tags["nf.container"]; 50 | assert.deepEqual(expected_tags, tags_from_env_vars()); 51 | 52 | clear_environment(); 53 | }); 54 | 55 | it("get tags from env vars with whitespace ignored", (): void => { 56 | setup_environment(); 57 | process.env.TITUS_CONTAINER_NAME = " main \t\t"; 58 | assert.deepEqual(all_expected_tags(), tags_from_env_vars()); 59 | clear_environment(); 60 | }); 61 | 62 | it("validate tags skips zero length strings", (): void => { 63 | const tags = {"a": "", "": "b", "c": "1"}; 64 | assert.deepEqual({"c": "1"}, validate_tags(tags)); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /test/config.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {Config} from "../src/index.js"; 3 | import process from "node:process"; 4 | import {describe, it} from "node:test"; 5 | 6 | describe("Config Tests", (): void => { 7 | function all_expected_tags(): Record { 8 | return { 9 | "nf.container": "main", 10 | "nf.process": "nodejs", 11 | }; 12 | } 13 | 14 | function setup_environment(): void { 15 | process.env.NETFLIX_PROCESS_NAME = "nodejs"; 16 | process.env.TITUS_CONTAINER_NAME = "main"; 17 | } 18 | 19 | function clear_environment(): void { 20 | const keys: string[] = ["NETFLIX_PROCESS_NAME", "SPECTATOR_OUTPUT_LOCATION", "TITUS_CONTAINER_NAME"]; 21 | 22 | for (const key of keys) { 23 | delete process.env[key]; 24 | } 25 | } 26 | 27 | function get_location(location: string): string { 28 | return new Config(location).location; 29 | } 30 | 31 | it("default config", (): void => { 32 | setup_environment(); 33 | const config = new Config(); 34 | assert.deepEqual(all_expected_tags(), config.extra_common_tags); 35 | assert.equal("udp", config.location); 36 | clear_environment(); 37 | }); 38 | 39 | it("env location override", (): void => { 40 | process.env.SPECTATOR_OUTPUT_LOCATION = "memory"; 41 | const config = new Config(); 42 | assert.equal("memory", config.location); 43 | clear_environment(); 44 | }); 45 | 46 | it("invalid env location override throws", (): void => { 47 | process.env.SPECTATOR_OUTPUT_LOCATION = "foo"; 48 | assert.throws((): Config => new Config()); 49 | clear_environment(); 50 | }); 51 | 52 | it("extra common tags", (): void => { 53 | setup_environment(); 54 | const config = new Config(undefined, {"extra-tag": "foo"}); 55 | assert.equal("udp", config.location); 56 | assert.deepEqual({"extra-tag": "foo", "nf.container": "main", "nf.process": "nodejs"}, config.extra_common_tags); 57 | clear_environment(); 58 | }); 59 | 60 | it("valid output locations", (): void => { 61 | assert.equal("none", get_location("none")); 62 | assert.equal("memory", get_location("memory")); 63 | assert.equal("stderr", get_location("stderr")); 64 | assert.equal("stdout", get_location("stdout")); 65 | assert.equal("udp", get_location("udp")); 66 | assert.equal("file://", get_location("file://")); 67 | assert.equal("udp://", get_location("udp://")); 68 | }); 69 | 70 | it("invalid output location throws", (): void => { 71 | assert.throws((): string => get_location("foo")); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /test/logger/logger.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {get_logger, Logger} from "../../src/index.js"; 3 | import {describe, it} from "node:test"; 4 | 5 | describe("Logger Tests", (): void => { 6 | 7 | it("default log level is info", (): void => { 8 | const log: Logger = get_logger(); 9 | assert.isTrue(log.is_level_enabled("fatal")); 10 | assert.isTrue(log.is_level_enabled("error")); 11 | assert.isTrue(log.is_level_enabled("warn")); 12 | assert.isTrue(log.is_level_enabled("info")); 13 | assert.isFalse(log.is_level_enabled("debug")); 14 | assert.isFalse(log.is_level_enabled("trace")); 15 | assert.isFalse(log.is_level_enabled("unknown")); 16 | 17 | const l2: Logger = get_logger("x"); 18 | assert.isTrue(l2.is_level_enabled("warn")); 19 | assert.isTrue(l2.is_level_enabled("info")); 20 | assert.isFalse(l2.is_level_enabled("debug")); 21 | }); 22 | 23 | it("log level filter", (): void => { 24 | const log: Logger = get_logger("debug"); 25 | assert.isTrue(log.is_level_enabled("info")); 26 | assert.isTrue(log.is_level_enabled("debug")); 27 | assert.isFalse(log.is_level_enabled("trace")); 28 | }); 29 | 30 | it("write to stdout", (): void => { 31 | const messages: string[] = []; 32 | const f = console.log; 33 | console.log = (msg: any): number => messages.push(msg); 34 | 35 | const log: Logger = get_logger("warn"); 36 | log.info("do nothing"); 37 | log.warn("stern warning"); 38 | log.error("error message"); 39 | 40 | assert.deepEqual(["WARN: stern warning", "ERROR: error message"], messages); 41 | console.log = f; 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/meter/age_gauge.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {AgeGauge, Id, MemoryWriter} from "../../src/index.js"; 3 | import {describe, it} from "node:test"; 4 | 5 | describe("AgeGauge Tests", (): void => { 6 | 7 | const tid = new Id("age_gauge"); 8 | 9 | it("now", (): void => { 10 | const g = new AgeGauge(tid, new MemoryWriter()); 11 | const writer = g.writer() as MemoryWriter; 12 | assert.isTrue(writer.is_empty()); 13 | 14 | g.now(); 15 | assert.equal("A:age_gauge:0", writer.last_line()); 16 | }); 17 | 18 | it("set", (): void => { 19 | const g = new AgeGauge(tid, new MemoryWriter()); 20 | const writer = g.writer() as MemoryWriter; 21 | assert.isTrue(writer.is_empty()); 22 | 23 | g.set(10); 24 | assert.equal("A:age_gauge:10", writer.last_line()); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/meter/counter.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {Counter, Id, MemoryWriter} from "../../src/index.js"; 3 | import {describe, it} from "node:test"; 4 | 5 | describe("Counter Tests", (): void => { 6 | 7 | const tid = new Id("counter"); 8 | 9 | it("increment", (): void => { 10 | const c = new Counter(tid, new MemoryWriter()); 11 | const writer = c.writer() as MemoryWriter; 12 | assert.isTrue(writer.is_empty()); 13 | 14 | c.increment(); 15 | assert.equal("c:counter:1", writer.last_line()); 16 | 17 | c.increment(2); 18 | assert.equal("c:counter:2", writer.last_line()); 19 | }); 20 | 21 | it("increment negative", (): void => { 22 | const c = new Counter(tid, new MemoryWriter()); 23 | const writer = c.writer() as MemoryWriter; 24 | c.increment(-1); 25 | assert.isTrue(writer.is_empty()); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/meter/dist_summary.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {DistributionSummary, Id, MemoryWriter} from "../../src/index.js"; 3 | import {describe, it} from "node:test"; 4 | 5 | describe("DistributionSummary Tests", (): void => { 6 | 7 | const tid = new Id("dist_summary"); 8 | 9 | it("record", (): void => { 10 | const d = new DistributionSummary(tid, new MemoryWriter()); 11 | const writer = d.writer() as MemoryWriter; 12 | assert.isTrue(writer.is_empty()); 13 | 14 | d.record(42); 15 | assert.equal("d:dist_summary:42", writer.last_line()); 16 | }); 17 | 18 | it("record negative", (): void => { 19 | const d = new DistributionSummary(tid, new MemoryWriter()); 20 | const writer = d.writer() as MemoryWriter; 21 | d.record(-42); 22 | assert.isTrue(writer.is_empty()); 23 | }); 24 | 25 | it("record zero", (): void => { 26 | const d = new DistributionSummary(tid, new MemoryWriter()); 27 | const writer = d.writer() as MemoryWriter; 28 | d.record(0); 29 | assert.equal("d:dist_summary:0", writer.last_line()); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/meter/gauge.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {Gauge, Id, MemoryWriter} from "../../src/index.js"; 3 | import {describe, it} from "node:test"; 4 | 5 | describe("Gauge Tests", (): void => { 6 | 7 | const tid = new Id("gauge"); 8 | 9 | it("set", (): void => { 10 | const g = new Gauge(tid, new MemoryWriter()); 11 | const writer = g.writer() as MemoryWriter; 12 | assert.isTrue(writer.is_empty()); 13 | 14 | g.set(1); 15 | assert.equal("g:gauge:1", writer.last_line()); 16 | }); 17 | 18 | it("ttl_seconds", (): void => { 19 | const g = new Gauge(tid, new MemoryWriter(), 120); 20 | const writer = g.writer() as MemoryWriter; 21 | g.set(42); 22 | assert.equal("g,120:gauge:42", writer.last_line()); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/meter/id.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {Id, Tags} from "../../src/index.js"; 3 | import {describe, it} from "node:test"; 4 | 5 | describe("Id Tests", (): void => { 6 | it("equals same name", (): void => { 7 | const id1 = new Id("foo"); 8 | const id2 = new Id("foo"); 9 | assert.equal(id1.spectatord_id, id2.spectatord_id); 10 | }); 11 | 12 | it("equals same tags", (): void => { 13 | const id1 = new Id("foo", {"a": "1", "b": "2", "c": "3"}); 14 | const id2 = new Id("foo", {"c": "3", "b": "2", "a": "1"}); 15 | assert.equal(id1.spectatord_id, id2.spectatord_id); 16 | }); 17 | 18 | it("illegal chars are replaced", (): void => { 19 | const id1 = new Id("test`!@#$%^&*()-=~_+[]{}\\|;:'\",<.>/?foo"); 20 | assert.equal("test______^____-_~______________.___foo", id1.spectatord_id); 21 | }); 22 | 23 | it("invalid tags", (): void => { 24 | const messages: string[] = []; 25 | const f = console.log; 26 | console.log = (msg: any): number => messages.push(msg); 27 | 28 | const expected: string[] = [ 29 | "WARN: Id(name=foo, tags={'k': ''}) is invalid due to tag keys or values which are zero-length strings; proceeding with truncated tags Id(name=foo, tags={})", 30 | ]; 31 | 32 | const id1 = new Id("foo", {"k": ""}); 33 | assert.equal("Id(name=foo, tags={})", id1.toString()); 34 | 35 | assert.deepEqual(expected, messages); 36 | console.log = f; 37 | }); 38 | 39 | it("name", (): void => { 40 | const id1 = new Id("foo", {"a": "1"}); 41 | assert.equal("foo", id1.name()); 42 | }); 43 | 44 | it("spectatord id", (): void => { 45 | const id1 = new Id("foo") 46 | assert.equal("foo", id1.spectatord_id); 47 | 48 | const id2 = new Id("bar", {"a": "1"}); 49 | assert.equal("bar,a=1", id2.spectatord_id); 50 | 51 | const id3 = new Id("baz", {"a": "1", "b": "2"}); 52 | assert.equal("baz,a=1,b=2", id3.spectatord_id); 53 | }); 54 | 55 | it("toString", (): void => { 56 | const id1 = new Id("foo"); 57 | assert.equal("Id(name=foo, tags={})", id1.toString()); 58 | 59 | const id2 = new Id("bar", {"a": "1"}); 60 | assert.equal("Id(name=bar, tags={'a': '1'})", id2.toString()); 61 | 62 | const id3 = new Id("baz", {"a": "1", "b": "2", "c": "3"}); 63 | assert.equal("Id(name=baz, tags={'a': '1', 'b': '2', 'c': '3'})", id3.toString()); 64 | }); 65 | 66 | it("tags", (): void => { 67 | const id1 = new Id("foo", {"a": "1"}); 68 | assert.deepEqual({"a": "1"}, id1.tags()); 69 | }); 70 | 71 | it("tags defensive copy", (): void => { 72 | const id1 = new Id("foo", {"a": "1"}); 73 | const tags: Tags = id1.tags(); 74 | tags["b"] = "2"; 75 | assert.deepEqual({"a": "1", "b": "2"}, tags); 76 | assert.deepEqual({"a": "1"}, id1.tags()); 77 | }); 78 | 79 | it("with_tag returns new object", (): void => { 80 | const id1 = new Id("foo"); 81 | const id2: Id = id1.with_tag("a", "1"); 82 | assert.notEqual(id1.spectatord_id, id2.spectatord_id); 83 | assert.deepEqual({}, id1.tags()); 84 | assert.deepEqual({"a": "1"}, id2.tags()); 85 | }); 86 | 87 | it("with_tags returns new object", (): void => { 88 | const id1 = new Id("foo"); 89 | const id2: Id = id1.with_tags({"a": "1", "b": "2"}); 90 | assert.notEqual(id1.spectatord_id, id2.spectatord_id); 91 | assert.deepEqual({}, id1.tags()); 92 | assert.deepEqual({"a": "1", "b": "2"}, id2.tags()); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /test/meter/max_gauge.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {Id, MaxGauge, MemoryWriter} from "../../src/index.js"; 3 | import {describe, it} from "node:test"; 4 | 5 | describe("MaxGauge Tests", (): void => { 6 | 7 | const tid = new Id("max_gauge"); 8 | 9 | it("set", (): void => { 10 | const g = new MaxGauge(tid, new MemoryWriter()); 11 | const writer = g.writer() as MemoryWriter; 12 | assert.isTrue(writer.is_empty()); 13 | 14 | g.set(0); 15 | assert.equal("m:max_gauge:0", writer.last_line()); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /test/meter/monotonic_counter.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {Id, MemoryWriter, MonotonicCounter} from "../../src/index.js"; 3 | import {describe, it} from "node:test"; 4 | 5 | describe("MonotonicCounter Tests", (): void => { 6 | 7 | const tid = new Id("monotonic_counter"); 8 | 9 | it("set", (): void => { 10 | const c = new MonotonicCounter(tid, new MemoryWriter()); 11 | const writer = c.writer() as MemoryWriter; 12 | assert.isTrue(writer.is_empty()); 13 | 14 | c.set(1); 15 | assert.equal("C:monotonic_counter:1", writer.last_line()); 16 | }); 17 | 18 | it("set negative", (): void => { 19 | const c = new MonotonicCounter(tid, new MemoryWriter()); 20 | const writer = c.writer() as MemoryWriter; 21 | assert.isTrue(writer.is_empty()); 22 | 23 | c.set(-1); 24 | assert.equal("C:monotonic_counter:-1", writer.last_line()); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/meter/monotonic_counter_uint.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {Id, MemoryWriter, MonotonicCounterUint} from "../../src/index.js"; 3 | import {describe, it} from "node:test"; 4 | 5 | describe("MonotonicCounterUint Tests", (): void => { 6 | 7 | const tid = new Id("monotonic_counter_uint"); 8 | 9 | it("set", (): void => { 10 | const c = new MonotonicCounterUint(tid, new MemoryWriter()); 11 | const writer = c.writer() as MemoryWriter; 12 | assert.isTrue(writer.is_empty()); 13 | 14 | c.set(BigInt(1)); 15 | assert.equal("U:monotonic_counter_uint:1", writer.last_line()); 16 | }); 17 | 18 | it("set negative", (): void => { 19 | const c = new MonotonicCounterUint(tid, new MemoryWriter()); 20 | const writer = c.writer() as MemoryWriter; 21 | assert.isTrue(writer.is_empty()); 22 | 23 | c.set(BigInt(-1)); 24 | assert.equal("U:monotonic_counter_uint:18446744073709551615", writer.last_line()); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/meter/percentile_dist_summary.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {Id, MemoryWriter, PercentileDistributionSummary} from "../../src/index.js"; 3 | import {describe, it} from "node:test"; 4 | 5 | describe("PercentileDistributionSummary Tests", (): void => { 6 | 7 | const tid = new Id("percentile_dist_summary"); 8 | 9 | it("record", (): void => { 10 | const d = new PercentileDistributionSummary(tid, new MemoryWriter()); 11 | const writer = d.writer() as MemoryWriter; 12 | assert.isTrue(writer.is_empty()); 13 | 14 | d.record(42); 15 | assert.equal("D:percentile_dist_summary:42", writer.last_line()); 16 | }); 17 | 18 | it("record negative", (): void => { 19 | const d = new PercentileDistributionSummary(tid, new MemoryWriter()); 20 | const writer = d.writer() as MemoryWriter; 21 | d.record(-42); 22 | assert.isTrue(writer.is_empty()); 23 | }); 24 | 25 | it("record zero", (): void => { 26 | const d = new PercentileDistributionSummary(tid, new MemoryWriter()); 27 | const writer = d.writer() as MemoryWriter; 28 | d.record(0); 29 | assert.equal("D:percentile_dist_summary:0", writer.last_line()); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/meter/percentile_timer.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {Id, MemoryWriter, PercentileTimer} from "../../src/index.js"; 3 | import {describe, it} from "node:test"; 4 | 5 | describe("PercentileTimer Tests", (): void => { 6 | 7 | const tid = new Id("percentile_timer"); 8 | 9 | it("record", (): void => { 10 | const t = new PercentileTimer(tid, new MemoryWriter()); 11 | const writer = t.writer() as MemoryWriter; 12 | assert.isTrue(writer.is_empty()); 13 | 14 | t.record(42); 15 | assert.equal("T:percentile_timer:42", writer.last_line()); 16 | }); 17 | 18 | it("record negative", (): void => { 19 | const t = new PercentileTimer(tid, new MemoryWriter()); 20 | const writer = t.writer() as MemoryWriter; 21 | t.record(-42); 22 | assert.isTrue(writer.is_empty()); 23 | }); 24 | 25 | it("record zero", (): void => { 26 | const t = new PercentileTimer(tid, new MemoryWriter()); 27 | const writer = t.writer() as MemoryWriter; 28 | t.record(0); 29 | assert.equal("T:percentile_timer:0", writer.last_line()); 30 | }); 31 | 32 | it("record latency from hrtime", (): void => { 33 | let nanos: number = 0; 34 | let round: number = 1; 35 | const f: NodeJS.HRTime = process.hrtime; 36 | Object.defineProperty(process, "hrtime", { 37 | get(): () => [number, number] { 38 | return (): [number, number] => { 39 | nanos += round * 1e6; // 1ms lag first time, 2ms second time, etc. 40 | ++round; 41 | return [0, nanos]; 42 | }; 43 | } 44 | }); 45 | 46 | const start: [number, number] = process.hrtime(); 47 | 48 | const t = new PercentileTimer(tid, new MemoryWriter()); 49 | const writer = t.writer() as MemoryWriter; 50 | t.record(process.hrtime(start)); // two calls to hrtime = 1ms + 2ms = 3ms 51 | assert.equal("T:percentile_timer:0.003", writer.last_line()); 52 | 53 | Object.defineProperty(process, "hrtime", f); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/meter/timer.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {Id, MemoryWriter, Timer} from "../../src/index.js"; 3 | import {describe, it} from "node:test"; 4 | 5 | describe("Timer Tests", (): void => { 6 | 7 | const tid = new Id("timer"); 8 | 9 | it("record", (): void => { 10 | const t = new Timer(tid, new MemoryWriter()); 11 | const writer = t.writer() as MemoryWriter; 12 | assert.isTrue(writer.is_empty()); 13 | 14 | t.record(42); 15 | assert.equal("t:timer:42", writer.last_line()); 16 | }); 17 | 18 | it("record negative", (): void => { 19 | const t = new Timer(tid, new MemoryWriter()); 20 | const writer = t.writer() as MemoryWriter; 21 | t.record(-42); 22 | assert.isTrue(writer.is_empty()); 23 | }); 24 | 25 | it("record zero", (): void => { 26 | const t = new Timer(tid, new MemoryWriter()); 27 | const writer = t.writer() as MemoryWriter; 28 | t.record(0); 29 | assert.equal("t:timer:0", writer.last_line()); 30 | }); 31 | 32 | it("record latency from hrtime", (): void => { 33 | let nanos: number = 0; 34 | let round: number = 1; 35 | const f: NodeJS.HRTime = process.hrtime; 36 | Object.defineProperty(process, "hrtime", { 37 | get(): () => [number, number] { 38 | return (): [number, number] => { 39 | nanos += round * 1e6; // 1ms lag first time, 2ms second time, etc. 40 | ++round; 41 | return [0, nanos]; 42 | }; 43 | } 44 | }); 45 | 46 | const start: [number, number] = process.hrtime(); 47 | 48 | const t = new Timer(tid, new MemoryWriter()); 49 | const writer = t.writer() as MemoryWriter; 50 | t.record(process.hrtime(start)); // two calls to hrtime = 1ms + 2ms = 3ms 51 | assert.equal("t:timer:0.003", writer.last_line()); 52 | 53 | Object.defineProperty(process, "hrtime", f); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/protocol_parser.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {parse_protocol_line} from "../src/index.js"; 3 | import {describe, it} from "node:test"; 4 | import {Id} from "../src/meter/id.js"; 5 | 6 | describe("Protocol Parser Tests", (): void => { 7 | it("parse invalid lines", (): void => { 8 | assert.throws((): [string, Id, string] => parse_protocol_line("foo")); 9 | assert.throws((): [string, Id, string] => parse_protocol_line("foo:bar")); 10 | assert.throws((): [string, Id, string] => parse_protocol_line("foo:bar,baz-quux")); 11 | }); 12 | 13 | it("parse counter", (): void => { 14 | const [symbol, id, value] = parse_protocol_line("c:counter:1"); 15 | assert.equal("c", symbol); 16 | assert.equal("counter", id.name()); 17 | assert.deepEqual({}, id.tags()); 18 | assert.equal("1", value); 19 | }); 20 | 21 | it("parse counter with tag", (): void => { 22 | const [symbol, id, value] = parse_protocol_line("c:counter,foo=bar:1"); 23 | assert.equal("c", symbol); 24 | assert.equal("counter", id.name()); 25 | assert.deepEqual({"foo": "bar"}, id.tags()); 26 | assert.equal("1", value); 27 | }); 28 | 29 | it("parse counter with multiple tags", (): void => { 30 | const [symbol, id, value] = parse_protocol_line("c:counter,foo=bar,baz=quux:1"); 31 | assert.equal("c", symbol); 32 | assert.equal("counter", id.name()); 33 | assert.deepEqual({"foo": "bar", "baz": "quux"}, id.tags()); 34 | assert.equal("1", value); 35 | }); 36 | 37 | it("parse gauge", (): void => { 38 | const [symbol, id, value] = parse_protocol_line("g:gauge:1"); 39 | assert.equal("g", symbol); 40 | assert.equal("gauge", id.name()); 41 | assert.deepEqual({}, id.tags()); 42 | assert.equal("1", value); 43 | }); 44 | 45 | it("parse gauge with ttl", (): void => { 46 | const [symbol, id, value] = parse_protocol_line("g,120:gauge:1"); 47 | assert.equal("g", symbol); 48 | assert.equal("gauge", id.name()); 49 | assert.deepEqual({}, id.tags()); 50 | assert.equal("1", value); 51 | }); 52 | 53 | it("parse timer", (): void => { 54 | const [symbol, id, value] = parse_protocol_line("t:timer:1"); 55 | assert.equal("t", symbol); 56 | assert.equal("timer", id.name()); 57 | assert.deepEqual({}, id.tags()); 58 | assert.equal("1", value); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /test/registry.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import { 3 | AgeGauge, 4 | Config, 5 | Counter, 6 | DistributionSummary, 7 | Gauge, 8 | get_logger, 9 | Id, 10 | isMemoryWriter, 11 | MaxGauge, 12 | MemoryWriter, 13 | MonotonicCounter, 14 | MonotonicCounterUint, 15 | PercentileDistributionSummary, 16 | PercentileTimer, 17 | Registry, 18 | Timer, UdpWriter 19 | } from "../src/index.js"; 20 | import {describe, it} from "node:test"; 21 | 22 | describe("Registry Tests", (): void => { 23 | it("close", (): void => { 24 | const r = new Registry(new Config("memory")); 25 | const writer = r.writer() as MemoryWriter; 26 | 27 | const c: Counter = r.counter("counter"); 28 | c.increment(); 29 | assert.equal("c:counter:1", writer.last_line()); 30 | 31 | r.close(); 32 | assert.isTrue(writer.is_empty()); 33 | }); 34 | 35 | it("default config", (): void => { 36 | const r = new Registry(); 37 | assert.isTrue(r.writer() instanceof UdpWriter); 38 | }); 39 | 40 | it("age_gauge", (): void => { 41 | const r = new Registry(new Config("memory")); 42 | const writer = r.writer() as MemoryWriter; 43 | 44 | const g1: AgeGauge = r.age_gauge("age_gauge"); 45 | const g2: AgeGauge = r.age_gauge("age_gauge", {"my-tags": "bar"}); 46 | assert.isTrue(writer.is_empty()); 47 | 48 | g1.set(1); 49 | assert.equal("A:age_gauge:1", writer.last_line()); 50 | 51 | g2.set(2); 52 | assert.equal("A:age_gauge,my-tags=bar:2", writer.last_line()); 53 | }); 54 | 55 | it("age_gauge_with_id", (): void => { 56 | const r = new Registry(new Config("memory", {"extra-tags": "foo"})); 57 | const writer = r.writer() as MemoryWriter; 58 | 59 | const g: AgeGauge = r.age_gauge_with_id(r.new_id("age_gauge", {"my-tags": "bar"})); 60 | assert.isTrue(writer.is_empty()); 61 | 62 | g.set(0); 63 | assert.equal("A:age_gauge,extra-tags=foo,my-tags=bar:0", writer.last_line()); 64 | }); 65 | 66 | it("counter", (): void => { 67 | const r = new Registry(new Config("memory")); 68 | const writer = r.writer() as MemoryWriter; 69 | 70 | const c1: Counter = r.counter("counter"); 71 | const c2: Counter = r.counter("counter", {"my-tags": "bar"}); 72 | assert.isTrue(writer.is_empty()); 73 | 74 | c1.increment(); 75 | assert.equal("c:counter:1", writer.last_line()); 76 | 77 | c2.increment(); 78 | assert.equal("c:counter,my-tags=bar:1", writer.last_line()); 79 | 80 | c1.increment(2); 81 | assert.equal("c:counter:2", writer.last_line()); 82 | 83 | c2.increment(2); 84 | assert.equal("c:counter,my-tags=bar:2", writer.last_line()); 85 | 86 | r.counter("counter").increment(3); 87 | assert.equal("c:counter:3", writer.last_line()); 88 | }); 89 | 90 | it("counter_with_id", (): void => { 91 | const r = new Registry(new Config("memory", {"extra-tags": "foo"})); 92 | const writer = r.writer() as MemoryWriter; 93 | 94 | const c: Counter = r.counter_with_id(r.new_id("counter", {"my-tags": "bar"})); 95 | assert.isTrue(writer.is_empty()); 96 | 97 | c.increment(); 98 | assert.equal("c:counter,extra-tags=foo,my-tags=bar:1", writer.last_line()); 99 | 100 | c.increment(2); 101 | assert.equal("c:counter,extra-tags=foo,my-tags=bar:2", writer.last_line()); 102 | 103 | r.counter("counter", {"my-tags": "bar"}).increment(3); 104 | assert.equal("c:counter,extra-tags=foo,my-tags=bar:3", writer.last_line()); 105 | }); 106 | 107 | it("distribution_summary", (): void => { 108 | const r = new Registry(new Config("memory")); 109 | const writer = r.writer() as MemoryWriter; 110 | 111 | const d: DistributionSummary = r.distribution_summary("distribution_summary"); 112 | assert.isTrue(writer.is_empty()); 113 | 114 | d.record(42); 115 | assert.equal("d:distribution_summary:42", writer.last_line()); 116 | }); 117 | 118 | it("distribution_summary_with_id", (): void => { 119 | const r = new Registry(new Config("memory", {"extra-tags": "foo"})); 120 | const writer = r.writer() as MemoryWriter; 121 | 122 | const d: DistributionSummary = r.distribution_summary_with_id(r.new_id("distribution_summary", {"my-tags": "bar"})); 123 | assert.isTrue(writer.is_empty()); 124 | 125 | d.record(42); 126 | assert.equal("d:distribution_summary,extra-tags=foo,my-tags=bar:42", writer.last_line()); 127 | }); 128 | 129 | it("gauge", (): void => { 130 | const r = new Registry(new Config("memory")); 131 | const writer = r.writer() as MemoryWriter; 132 | 133 | const g: Gauge = r.gauge("gauge"); 134 | assert.isTrue(writer.is_empty()); 135 | 136 | g.set(42); 137 | assert.equal("g:gauge:42", writer.last_line()); 138 | }); 139 | 140 | it("gauge with ttl seconds", (): void => { 141 | const r = new Registry(new Config("memory")); 142 | const writer = r.writer() as MemoryWriter; 143 | 144 | const g: Gauge = r.gauge("gauge", undefined, 120); 145 | assert.isTrue(writer.is_empty()); 146 | 147 | g.set(42); 148 | assert.equal("g,120:gauge:42", writer.last_line()); 149 | }); 150 | 151 | it("gauge_with_id", (): void => { 152 | const r = new Registry(new Config("memory", {"extra-tags": "foo"})); 153 | const writer = r.writer() as MemoryWriter; 154 | 155 | const g: Gauge = r.gauge_with_id(r.new_id("gauge", {"my-tags": "bar"})); 156 | assert.isTrue(writer.is_empty()); 157 | 158 | g.set(42); 159 | assert.equal("g:gauge,extra-tags=foo,my-tags=bar:42", writer.last_line()); 160 | }); 161 | 162 | it("gauge_with_id with ttl seconds", (): void => { 163 | const r = new Registry(new Config("memory", {"extra-tags": "foo"})); 164 | const writer = r.writer() as MemoryWriter; 165 | 166 | const g: Gauge = r.gauge_with_id(r.new_id("gauge", {"my-tags": "bar"}), 120); 167 | assert.isTrue(writer.is_empty()); 168 | 169 | g.set(42); 170 | assert.equal("g,120:gauge,extra-tags=foo,my-tags=bar:42", writer.last_line()); 171 | }); 172 | 173 | it("max_gauge", (): void => { 174 | const r = new Registry(new Config("memory")); 175 | const writer = r.writer() as MemoryWriter; 176 | 177 | const g: MaxGauge = r.max_gauge("max_gauge"); 178 | assert.isTrue(writer.is_empty()); 179 | 180 | g.set(42); 181 | assert.equal("m:max_gauge:42", writer.last_line()); 182 | }); 183 | 184 | it("max_gauge_with_id", (): void => { 185 | const r = new Registry(new Config("memory", {"extra-tags": "foo"})); 186 | const writer = r.writer() as MemoryWriter; 187 | 188 | const g: MaxGauge = r.max_gauge_with_id(r.new_id("max_gauge", {"my-tags": "bar"})); 189 | assert.isTrue(writer.is_empty()); 190 | 191 | g.set(42); 192 | assert.equal("m:max_gauge,extra-tags=foo,my-tags=bar:42", writer.last_line()); 193 | }); 194 | 195 | it("monotonic_counter", (): void => { 196 | const r = new Registry(new Config("memory")); 197 | const writer = r.writer() as MemoryWriter; 198 | 199 | const c: MonotonicCounter = r.monotonic_counter("monotonic_counter"); 200 | assert.isTrue(writer.is_empty()); 201 | 202 | c.set(42); 203 | assert.equal("C:monotonic_counter:42", writer.last_line()); 204 | }); 205 | 206 | it("monotonic_counter_with_id", (): void => { 207 | const r = new Registry(new Config("memory", {"extra-tags": "foo"})); 208 | const writer = r.writer() as MemoryWriter; 209 | 210 | const c: MonotonicCounter = r.monotonic_counter_with_id(r.new_id("monotonic_counter", {"my-tags": "bar"})); 211 | assert.isTrue(writer.is_empty()); 212 | 213 | c.set(42); 214 | assert.equal("C:monotonic_counter,extra-tags=foo,my-tags=bar:42", writer.last_line()); 215 | }); 216 | 217 | it("monotonic_counter_uint", (): void => { 218 | const r = new Registry(new Config("memory")); 219 | const writer = r.writer() as MemoryWriter; 220 | 221 | const c: MonotonicCounterUint = r.monotonic_counter_uint("monotonic_counter_uint"); 222 | assert.isTrue(writer.is_empty()); 223 | 224 | c.set(BigInt(42)); 225 | assert.equal("U:monotonic_counter_uint:42", writer.last_line()); 226 | }); 227 | 228 | it("monotonic_counter_uint_with_id", (): void => { 229 | const r = new Registry(new Config("memory", {"extra-tags": "foo"})); 230 | const writer = r.writer() as MemoryWriter; 231 | 232 | const c: MonotonicCounterUint = r.monotonic_counter_uint_with_id(r.new_id("monotonic_counter_uint", {"my-tags": "bar"})); 233 | assert.isTrue(writer.is_empty()); 234 | 235 | c.set(BigInt(42)); 236 | assert.equal("U:monotonic_counter_uint,extra-tags=foo,my-tags=bar:42", writer.last_line()); 237 | }); 238 | 239 | it("new_id", (): void => { 240 | const r1 = new Registry(new Config("memory")); 241 | const id1: Id = r1.new_id("id"); 242 | assert.equal("Id(name=id, tags={})", id1.toString()); 243 | 244 | const r2 = new Registry(new Config("memory", {"extra-tags": "foo"})); 245 | const id2: Id = r2.new_id("id"); 246 | assert.equal("Id(name=id, tags={'extra-tags': 'foo'})", id2.toString()); 247 | }); 248 | 249 | it("pct_distribution_summary", (): void => { 250 | const r = new Registry(new Config("memory")); 251 | const writer = r.writer() as MemoryWriter; 252 | 253 | const d: PercentileDistributionSummary = r.pct_distribution_summary("pct_distribution_summary"); 254 | assert.isTrue(writer.is_empty()); 255 | 256 | d.record(42); 257 | assert.equal("D:pct_distribution_summary:42", writer.last_line()); 258 | }); 259 | 260 | it("pct_distribution_summary_with_id", (): void => { 261 | const r = new Registry(new Config("memory", {"extra-tags": "foo"})); 262 | const writer = r.writer() as MemoryWriter; 263 | 264 | const d: PercentileDistributionSummary = r.pct_distribution_summary_with_id(r.new_id("pct_distribution_summary", {"my-tags": "bar"})); 265 | assert.isTrue(writer.is_empty()); 266 | 267 | d.record(42); 268 | assert.equal("D:pct_distribution_summary,extra-tags=foo,my-tags=bar:42", writer.last_line()); 269 | }); 270 | 271 | it("pct_timer", (): void => { 272 | const r = new Registry(new Config("memory")); 273 | const writer = r.writer() as MemoryWriter; 274 | 275 | const t: PercentileTimer = r.pct_timer("pct_timer"); 276 | assert.isTrue(writer.is_empty()); 277 | 278 | t.record(42); 279 | assert.equal("T:pct_timer:42", writer.last_line()); 280 | }); 281 | 282 | it("pct_timer_with_id", (): void => { 283 | const r = new Registry(new Config("memory", {"extra-tags": "foo"})); 284 | const writer = r.writer() as MemoryWriter; 285 | 286 | const t: PercentileTimer = r.pct_timer_with_id(r.new_id("pct_timer", {"my-tags": "bar"})); 287 | assert.isTrue(writer.is_empty()); 288 | 289 | t.record(42); 290 | assert.equal("T:pct_timer,extra-tags=foo,my-tags=bar:42", writer.last_line()); 291 | }); 292 | 293 | it("timer", (): void => { 294 | const r = new Registry(new Config("memory")); 295 | const writer = r.writer() as MemoryWriter; 296 | 297 | const t: Timer = r.timer("timer"); 298 | assert.isTrue(writer.is_empty()); 299 | 300 | t.record(42); 301 | assert.equal("t:timer:42", writer.last_line()); 302 | }); 303 | 304 | it("timer_with_id", (): void => { 305 | const r = new Registry(new Config("memory", {"extra-tags": "foo"})); 306 | const writer = r.writer() as MemoryWriter; 307 | 308 | const t: Timer = r.timer_with_id(r.new_id("timer", {"my-tags": "bar"})); 309 | assert.isTrue(writer.is_empty()); 310 | 311 | t.record(42); 312 | assert.equal("t:timer,extra-tags=foo,my-tags=bar:42", writer.last_line()); 313 | }); 314 | 315 | it("writer", (): void => { 316 | const r = new Registry(new Config("memory")); 317 | assert.isTrue(isMemoryWriter(r.writer())); 318 | }); 319 | 320 | it("writer debug logging", (): void => { 321 | const messages: string[] = []; 322 | const f = console.log; 323 | console.log = (msg: any): number => messages.push(msg); 324 | 325 | const r = new Registry(new Config("memory", undefined, get_logger("debug"))); 326 | const writer = r.writer() as MemoryWriter; 327 | assert.isTrue(writer.is_empty()); 328 | 329 | const c: Counter = r.counter("counter"); 330 | c.increment(); 331 | 332 | r.logger.debug("use registry logger to leave a message"); 333 | 334 | const expected_messages = [ 335 | "DEBUG: initialize MemoryWriter", 336 | "DEBUG: Create Registry with extra_common_tags={}", 337 | "DEBUG: write line=c:counter:1", 338 | "DEBUG: use registry logger to leave a message" 339 | ]; 340 | assert.deepEqual(expected_messages, messages); 341 | 342 | console.log = f; 343 | }); 344 | }); 345 | -------------------------------------------------------------------------------- /test/writer/file_writer.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {FileWriter, new_writer} from "../../src/index.js"; 3 | import {fileURLToPath} from "node:url"; 4 | import fs from "node:fs"; 5 | import os from "node:os"; 6 | import path from "node:path"; 7 | import {after, before, describe, it} from "node:test"; 8 | 9 | describe("FileWriter Tests", (): void => { 10 | 11 | let tmpdir: string; 12 | let location: string; 13 | 14 | before((): void => { 15 | tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "spectator-js-")); 16 | location = `file://${tmpdir}/print-writer.txt`; 17 | }); 18 | 19 | after((): void => { 20 | fs.rmSync(tmpdir, {recursive: true, force: true}); 21 | }); 22 | 23 | it("temporary file", async (): Promise => { 24 | const writer = new_writer(location) as FileWriter; 25 | await writer.write("c:server.numRequests,id=failed:1"); 26 | await writer.write("c:server.numRequests,id=failed:2"); 27 | 28 | let data: string = ""; 29 | 30 | try { 31 | data = fs.readFileSync(fileURLToPath(location), "utf8"); 32 | } catch (error) { 33 | if (error instanceof Error) { 34 | console.error(`failed to read file: ${error.message}`); 35 | } 36 | } 37 | 38 | const expected: string[] = [ 39 | "c:server.numRequests,id=failed:1", 40 | "c:server.numRequests,id=failed:2" 41 | ]; 42 | 43 | assert.deepEqual(data.trim().split("\n"), expected); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/writer/memory_writer.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {MemoryWriter, new_writer} from "../../src/index.js"; 3 | import {describe, it} from "node:test"; 4 | 5 | describe("MemoryWriter Tests", (): void => { 6 | 7 | it("all methods", (): void => { 8 | const memory_writer = new_writer("memory") as MemoryWriter; 9 | assert.isTrue(memory_writer.is_empty()); 10 | 11 | memory_writer.write("c:counter:1"); 12 | memory_writer.write("g:gauge:2"); 13 | assert.deepEqual(["c:counter:1", "g:gauge:2"], memory_writer.get()); 14 | assert.equal("g:gauge:2", memory_writer.last_line()); 15 | 16 | memory_writer.clear(); 17 | assert.isTrue(memory_writer.is_empty()); 18 | 19 | memory_writer.write("c:counter:1"); 20 | memory_writer.write("g:gauge:2"); 21 | memory_writer.close(); 22 | assert.isTrue(memory_writer.is_empty()); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/writer/noop_writer.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {get_logger, new_writer, NoopWriter} from "../../src/index.js"; 3 | import {describe, it} from "node:test"; 4 | 5 | describe("NoopWriter Tests", (): void => { 6 | 7 | it("logs", (): void => { 8 | const messages: string[] = []; 9 | const f = console.log; 10 | console.log = (msg: any): number => messages.push(msg); 11 | 12 | const noop_writer = new_writer("none", get_logger("debug")) as NoopWriter; 13 | noop_writer.write("c:counter:1"); 14 | noop_writer.close(); 15 | 16 | const expected_messages: string[] = [ 17 | "DEBUG: initialize NoopWriter", 18 | "DEBUG: write line=c:counter:1" 19 | ]; 20 | 21 | assert.deepEqual(expected_messages, messages); 22 | console.log = f; 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/writer/udp_writer.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {Config, new_writer, Registry, UdpWriter} from "../../src/index.js"; 3 | import {AddressInfo, isIPv4, isIPv6} from "node:net"; 4 | import {createSocket, Socket} from "node:dgram"; 5 | import {after, before, describe, it} from "node:test"; 6 | 7 | describe("UdpWriter Tests", (): void => { 8 | 9 | let server: Socket; 10 | let location: string; 11 | const messages: string[] = []; 12 | 13 | function sleep(ms: number): Promise { 14 | return new Promise((resolve: (value: void | PromiseLike) => void): void => { 15 | setTimeout(resolve, ms); 16 | }); 17 | } 18 | 19 | before((): Promise => { 20 | return new Promise((resolve: (value: void | PromiseLike) => void): void => { 21 | server = createSocket("udp4"); 22 | server.on("error", (err: Error): void => { 23 | console.error('Server error:', err); 24 | server.close(); 25 | }); 26 | server.on("message", (msg: Buffer): void => { 27 | messages.push(msg.toString()); 28 | }); 29 | server.bind(0, "127.0.0.1", (): void => { 30 | const address: AddressInfo = server.address(); 31 | location = `udp://${address.address}:${address.port}`; 32 | resolve(); 33 | }); 34 | }); 35 | }); 36 | 37 | after((): Promise => { 38 | return new Promise((resolve: (value: void | PromiseLike) => void): void => { 39 | server.close(); 40 | resolve(); 41 | }); 42 | }); 43 | 44 | it("send metrics", async (): Promise => { 45 | const writer = new_writer(location) as UdpWriter; 46 | 47 | await writer.write("c:server.numRequests,id=failed:1"); 48 | await writer.write("c:server.numRequests,id=failed:2"); 49 | await writer.close(); 50 | 51 | await sleep(2); // tiny pause is necessary to see data 52 | 53 | assert.equal(messages.length, 2); 54 | assert.equal(messages[0], "c:server.numRequests,id=failed:1"); 55 | assert.equal(messages[1], "c:server.numRequests,id=failed:2"); 56 | 57 | messages.length = 0; 58 | }); 59 | 60 | it("using registry", async (): Promise => { 61 | const r = new Registry(new Config(location)); 62 | 63 | await r.counter("server.numRequests", {"id": "success"}).increment() 64 | await r.counter("server.numRequests", {"id": "success"}).increment(2) 65 | await r.close() 66 | 67 | await sleep(2); // tiny pause is necessary to see data 68 | 69 | assert.equal(messages.length, 2); 70 | assert.equal(messages[0], "c:server.numRequests,id=success:1"); 71 | assert.equal(messages[1], "c:server.numRequests,id=success:2"); 72 | }); 73 | 74 | it("address family", (): void => { 75 | assert.equal(isIPv4("192.168.1.1"), true); 76 | assert.equal(isIPv4("2001:0db8:85a3:0000:0000:8a2e:0370:7334"), false); 77 | assert.equal(isIPv4("::1"), false); 78 | assert.equal(isIPv4("[::1]"), false); 79 | assert.equal(isIPv4("invalid-ip"), false); 80 | 81 | assert.equal(isIPv6("192.168.1.1"), false); 82 | assert.equal(isIPv6("2001:0db8:85a3:0000:0000:8a2e:0370:7334"), true); 83 | assert.equal(isIPv6("::1"), true); 84 | assert.equal(isIPv6("[::1]"), false); 85 | assert.equal(isIPv6("invalid-ip"), false); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Language and Environment */ 6 | "target": "es2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 7 | 8 | /* Modules */ 9 | "module": "nodenext", /* Specify what module code is generated. */ 10 | "rootDir": "./", /* Specify the root folder within your source files. */ 11 | 12 | /* Emit */ 13 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 14 | "outDir": "./build", /* Specify an output folder for all emitted files. */ 15 | 16 | /* Interop Constraints */ 17 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 18 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 19 | 20 | /* Type Checking */ 21 | "strict": true, /* Enable all strict type-checking options. */ 22 | 23 | /* Completeness */ 24 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 25 | } 26 | } 27 | --------------------------------------------------------------------------------