Move packages to huly.core

This commit is contained in:
Andrey Sobolev
2025-10-16 00:52:37 +07:00
parent 00bc9ced41
commit 063b52c1ca
39 changed files with 2 additions and 5796 deletions
+2 -4
View File
@@ -1,6 +1,7 @@
# Huly Utils
Huly Utils is a monorepo managed by [Rush](https://rushjs.io/) that contains various packages and tools for engineering purposes. This repository is structured to support scalable development and efficient collaboration.
Huly Utils is a monorepo managed by [Rush](https://rushjs.io/) that contains build tools to Huly platform.
This repository is structured to support scalable development and efficient collaboration.
## Repository Structure
@@ -13,10 +14,7 @@ common/
scripts/ # Utility scripts for managing the repository
temp/ # Temporary files and caches
packages/
measurements/ # Measurement utilities
measurements-otlp/ # OpenTelemetry-based measurement utilities
platform-rig/ # Platform rig utilities
postgres-base/ # PostgreSQL base utilities
```
## Getting Started
-1748
View File
File diff suppressed because it is too large Load Diff
-7
View File
@@ -1,7 +0,0 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
-8
View File
@@ -1,8 +0,0 @@
*
!/lib/**
!/types/**
!/src/**
!CHANGELOG.md
/lib/**/__test__/
/types/**/__test__/
/src/**/__test__/
-62
View File
@@ -1,62 +0,0 @@
{
"name": "@hcengineering/measurements-otlp",
"entries": [
{
"version": "0.7.12",
"tag": "@hcengineering/measurements-otlp_v0.7.12",
"date": "Sat, 11 Oct 2025 19:18:56 GMT",
"comments": {
"patch": [
{
"comment": "rollback eslint"
}
],
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/measurements\" from `^0.7.11` to `0.7.12`"
},
{
"comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.14` to `0.7.15`"
}
]
}
},
{
"version": "0.7.11",
"tag": "@hcengineering/measurements-otlp_v0.7.11",
"date": "Sat, 11 Oct 2025 17:58:53 GMT",
"comments": {
"none": [
{
"comment": "Add tests"
}
],
"patch": [
{
"comment": "Fix eslint deps"
}
],
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/measurements\" from `^0.7.10` to `0.7.11`"
},
{
"comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.12` to `0.7.13`"
}
]
}
},
{
"version": "0.7.10",
"tag": "@hcengineering/measurements-otlp_v0.7.10",
"date": "Fri, 10 Oct 2025 12:32:59 GMT",
"comments": {
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.10` to `0.7.11`"
}
]
}
}
]
}
-23
View File
@@ -1,23 +0,0 @@
# Change Log - @hcengineering/measurements-otlp
This log was last generated on Sat, 11 Oct 2025 19:18:56 GMT and should not be manually modified.
## 0.7.12
Sat, 11 Oct 2025 19:18:56 GMT
### Patches
- rollback eslint
## 0.7.11
Sat, 11 Oct 2025 17:58:53 GMT
### Patches
- Fix eslint deps
## 0.7.10
Fri, 10 Oct 2025 12:32:59 GMT
_Initial release_
@@ -1,4 +0,0 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig"
}
@@ -1,7 +0,0 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ["./src"],
coverageReporters: ["text-summary", "html"]
}
-71
View File
@@ -1,71 +0,0 @@
{
"name": "@hcengineering/measurements-otlp",
"version": "0.7.12",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
"files": [
"lib/**/*",
"!lib/**/__test__/**",
"types/**/*",
"!types/**/__test__/**",
"src/**/*",
"!src/**/__test__/**",
"README.md",
"CHANGELOG.md"
],
"author": "Anticrm Platform Contributors",
"license": "EPL-2.0",
"scripts": {
"build": "compile",
"build:watch": "compile",
"test": "jest --passWithNoTests --silent",
"format": "format src",
"_phase:build": "compile transpile src",
"_phase:test": "jest --passWithNoTests --silent",
"_phase:format": "format src",
"_phase:validate": "compile validate"
},
"devDependencies": {
"@hcengineering/platform-rig": "workspace:^0.7.19",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint": "^8.54.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-n": "^15.4.0"
},
"dependencies": {
"@hcengineering/measurements": "workspace:^0.7.13",
"@opentelemetry/sdk-node": "^0.203.0",
"@opentelemetry/sdk-logs": "^0.203.0",
"@opentelemetry/auto-instrumentations-node": "^0.62.0",
"@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-trace-node": "^2.0.1",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/core": "^2.0.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.203.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.203.0",
"@opentelemetry/otlp-exporter-base": "^0.203.0",
"@opentelemetry/id-generator-aws-xray": "^2.0.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.203.0",
"@opentelemetry/api-logs": "^0.203.0",
"@opentelemetry/sdk-metrics": "^2.0.1"
},
"repository": "https://github.com/hcengineering/huly.utils",
"exports": {
".": {
"types": "./types/index.d.ts",
"require": "./lib/index.js",
"import": "./lib/index.js"
}
},
"publishConfig": {
"access": "public"
}
}
@@ -1,451 +0,0 @@
import { newMetrics, type MeasureLogger } from '@hcengineering/measurements'
import { context, trace } from '@opentelemetry/api'
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'
import { OpenTelemetryMetricsContext } from '../telemetry'
describe('telemetry', () => {
let tracer: any
let provider: NodeTracerProvider
beforeAll(() => {
provider = new NodeTracerProvider()
provider.register()
tracer = trace.getTracer('test-tracer')
})
afterAll(() => {
void provider.shutdown()
})
describe('OpenTelemetryMetricsContext', () => {
const mockLogger: MeasureLogger = {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
close: jest.fn(async () => {}),
logOperation: jest.fn()
}
beforeEach(() => {
jest.clearAllMocks()
})
it('should create a new context with tracer', () => {
const metrics = newMetrics()
const ctx = new OpenTelemetryMetricsContext(
'test',
tracer,
undefined,
undefined,
{ op: 'create' },
{},
metrics,
mockLogger
)
expect(ctx).toBeDefined()
expect(ctx.metrics).toBe(metrics)
expect(ctx.logger).toBe(mockLogger)
})
it('should create child context with span', () => {
const metrics = newMetrics()
const ctx = new OpenTelemetryMetricsContext(
'parent',
tracer,
context.active(),
undefined,
{},
{},
metrics,
mockLogger
)
const child = ctx.newChild('child', { op: 'child' }, { span: true })
expect(child).toBeDefined()
expect(child.parent).toBe(ctx)
})
it('should create child context without span when span is false', () => {
const metrics = newMetrics()
const ctx = new OpenTelemetryMetricsContext(
'parent',
tracer,
context.active(),
undefined,
{},
{},
metrics,
mockLogger
)
const child = ctx.newChild('child', { op: 'child' }, { span: false })
expect(child).toBeDefined()
})
it('should execute async operation with context', async () => {
const metrics = newMetrics()
const ctx = new OpenTelemetryMetricsContext(
'test',
tracer,
context.active(),
undefined,
{},
{},
metrics,
mockLogger
)
let executed = false
await ctx.with('operation', { op: 'test' }, async () => {
executed = true
await new Promise((resolve) => setTimeout(resolve, 10))
})
expect(executed).toBe(true)
expect(metrics.measurements.operation).toBeDefined()
})
it('should execute sync operation with context', () => {
const metrics = newMetrics()
const ctx = new OpenTelemetryMetricsContext(
'test',
tracer,
context.active(),
undefined,
{},
{},
metrics,
mockLogger
)
const result = ctx.withSync('operation', { op: 'test' }, () => {
return 42
})
expect(result).toBe(42)
expect(metrics.measurements.operation).toBeDefined()
})
it('should handle errors in async operations', async () => {
const metrics = newMetrics()
const span = tracer.startSpan('test-span')
const ctx = new OpenTelemetryMetricsContext('test', tracer, context.active(), span, {}, {}, metrics, mockLogger)
await expect(
ctx.with('operation', { op: 'test' }, async () => {
throw new Error('Test error')
})
).rejects.toThrow('Test error')
expect(metrics.measurements.operation).toBeDefined()
})
it('should measure custom value with meter', () => {
const metrics = newMetrics()
const mockMeter = {
getCounter: jest.fn(() => ({
counter: { record: jest.fn() },
value: 0
}))
}
const ctx = new OpenTelemetryMetricsContext(
'test',
tracer,
undefined,
undefined,
{ op: 'test' },
{},
metrics,
mockLogger,
undefined,
undefined,
undefined,
mockMeter as any
)
ctx.measure('custom', 100)
expect(mockMeter.getCounter).toHaveBeenCalledWith('custom')
})
it('should log info with OTLP logger', () => {
const metrics = newMetrics()
const mockOtlpLogger = {
emit: jest.fn()
}
const ctx = new OpenTelemetryMetricsContext(
'test',
tracer,
context.active(),
undefined,
{ op: 'test' },
{},
metrics,
mockLogger,
undefined,
undefined,
mockOtlpLogger as any
)
ctx.info('Test message', { key: 'value' })
expect(mockOtlpLogger.emit).toHaveBeenCalled()
expect(mockLogger.info).toHaveBeenCalled()
})
it('should log error with OTLP logger', () => {
const metrics = newMetrics()
const mockOtlpLogger = {
emit: jest.fn()
}
const ctx = new OpenTelemetryMetricsContext(
'test',
tracer,
context.active(),
undefined,
{ op: 'test' },
{},
metrics,
mockLogger,
undefined,
undefined,
mockOtlpLogger as any
)
ctx.error('Error message', { error: 'details' })
expect(mockOtlpLogger.emit).toHaveBeenCalled()
expect(mockLogger.error).toHaveBeenCalled()
})
it('should log warn with OTLP logger', () => {
const metrics = newMetrics()
const mockOtlpLogger = {
emit: jest.fn()
}
const ctx = new OpenTelemetryMetricsContext(
'test',
tracer,
context.active(),
undefined,
{ op: 'test' },
{},
metrics,
mockLogger,
undefined,
undefined,
mockOtlpLogger as any
)
ctx.warn('Warning message', { warning: 'info' })
expect(mockOtlpLogger.emit).toHaveBeenCalled()
expect(mockLogger.warn).toHaveBeenCalled()
})
it('should extract metadata from context', () => {
const metrics = newMetrics()
const ctx = new OpenTelemetryMetricsContext(
'test',
tracer,
context.active(),
undefined,
{},
{},
metrics,
mockLogger
)
const meta = ctx.extractMeta()
expect(meta).toBeDefined()
expect(typeof meta).toBe('object')
})
it('should get params', () => {
const metrics = newMetrics()
const ctx = new OpenTelemetryMetricsContext(
'test',
tracer,
undefined,
undefined,
{ op: 'test', method: 'GET' },
{},
metrics,
mockLogger
)
const params = ctx.getParams()
expect(params).toEqual({ op: 'test', method: 'GET' })
})
it('should share contextData with children', () => {
const metrics = newMetrics()
const ctx = new OpenTelemetryMetricsContext(
'parent',
tracer,
context.active(),
undefined,
{},
{},
metrics,
mockLogger
)
ctx.contextData = { userId: '123' }
const child = ctx.newChild('child', {})
expect(child.contextData).toBe(ctx.contextData)
})
it('should inherit params when inheritParams is true', async () => {
const metrics = newMetrics()
const ctx = new OpenTelemetryMetricsContext(
'parent',
tracer,
context.active(),
undefined,
{ parentKey: 'parentValue' },
{},
metrics,
mockLogger
)
await ctx.with(
'child',
{ childKey: 'childValue' },
async (childCtx) => {
expect(childCtx.getParams()).toEqual({ childKey: 'childValue' })
},
{},
{ inheritParams: false }
)
await ctx.with(
'child',
{ childKey: 'childValue' },
async (childCtx) => {
expect(childCtx.getParams()).toEqual({
parentKey: 'parentValue',
childKey: 'childValue'
})
},
{},
{ inheritParams: true }
)
})
it('should not call end() twice', () => {
const metrics = newMetrics()
const span = tracer.startSpan('test-span')
const spanEndSpy = jest.spyOn(span, 'end')
const ctx = new OpenTelemetryMetricsContext('test', tracer, context.active(), span, {}, {}, metrics, mockLogger)
ctx.end()
ctx.end() // Second call should be no-op
expect(spanEndSpy).toHaveBeenCalledTimes(1)
})
it('should handle null return value', async () => {
const metrics = newMetrics()
const ctx = new OpenTelemetryMetricsContext(
'test',
tracer,
context.active(),
undefined,
{},
{},
metrics,
mockLogger
)
const result = await ctx.with('operation', {}, () => null)
expect(result).toBeUndefined()
})
it('should log operation when log option is true', async () => {
const metrics = newMetrics()
const ctx = new OpenTelemetryMetricsContext(
'test',
tracer,
context.active(),
undefined,
{},
{},
metrics,
mockLogger
)
await ctx.with(
'operation',
{ op: 'test' },
async () => {
await new Promise((resolve) => setTimeout(resolve, 10))
},
{},
{ log: true }
)
expect(mockLogger.logOperation).toHaveBeenCalledWith(
'operation',
expect.any(Number),
expect.objectContaining({ op: 'test' })
)
})
it('should suppress tracing when span is disable', () => {
const metrics = newMetrics()
const ctx = new OpenTelemetryMetricsContext(
'parent',
tracer,
context.active(),
undefined,
{},
{},
metrics,
mockLogger
)
const child = ctx.newChild('child', {}, { span: 'disable' })
expect(child).toBeDefined()
})
it('should set span attributes from params', () => {
const metrics = newMetrics()
const ctx = new OpenTelemetryMetricsContext(
'parent',
tracer,
context.active(),
undefined,
{},
{},
metrics,
mockLogger
)
// When creating a child with span: true, attributes are set on the child's span
const child = ctx.newChild(
'child',
{ key1: 'value1', key2: 'value2' },
{ span: true }
) as OpenTelemetryMetricsContext
// The child should have been created successfully
expect(child).toBeDefined()
expect(child.parent).toBe(ctx)
})
})
})
-1
View File
@@ -1 +0,0 @@
export * from './telemetry'
-632
View File
@@ -1,632 +0,0 @@
import {
childMetrics,
consoleLogger,
MeasureContext,
MeasureMetricsContext,
newMetrics,
noParamsLogger,
nullPromise,
platformNow,
platformNowDiff,
updateMeasure,
type FullParamsType,
type MeasureLogger,
type Metrics,
type ParamsType,
type WithOptions
} from '@hcengineering/measurements'
import {
context,
metrics as otelMetrics,
propagation,
Span,
SpanStatusCode,
trace,
type Context,
type Gauge,
type Meter,
type Tracer
} from '@opentelemetry/api'
import { Logger, SeverityNumber } from '@opentelemetry/api-logs'
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
import { suppressTracing } from '@opentelemetry/core'
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { AWSXRayIdGenerator } from '@opentelemetry/id-generator-aws-xray'
import { CompressionAlgorithm } from '@opentelemetry/otlp-exporter-base'
import { resourceFromAttributes } from '@opentelemetry/resources'
import { BatchLogRecordProcessor, LoggerProvider } from '@opentelemetry/sdk-logs'
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'
import { NodeSDK } from '@opentelemetry/sdk-node'
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-node'
class MetricsContext {
counters = new Map<string, { counter: Gauge, value: 0 }>()
constructor (readonly meter?: Meter) {}
getCounter (name: string): { counter: Gauge, value: number } | undefined {
if (this.meter === undefined) {
return undefined
}
let counter = this.counters.get(name)
if (counter === undefined) {
counter = { counter: this.meter.createGauge(name), value: 0 }
this.counters.set(name, counter)
}
return counter
}
}
/**
* @public
*/
export class OpenTelemetryMetricsContext implements MeasureContext {
private readonly name: string
private readonly params: ParamsType
private readonly fullParams: FullParamsType | (() => FullParamsType) = {}
logger: MeasureLogger
metrics: Metrics
id?: string
st = platformNow()
contextData: object = {}
isDone = false
doneTrace: string = ''
private done (value?: number, override?: boolean): void {
if (!this.isDone) {
this.doneTrace = new Error().stack ?? ''
this.isDone = true
updateMeasure(this.metrics, this.st, this.params, this.fullParams, (spend) => {}, value, override)
this.span?.end()
}
}
constructor (
name: string,
readonly tracer: Tracer,
readonly context: Context | undefined,
readonly span: Span | undefined,
params: ParamsType,
fullParams: FullParamsType | (() => FullParamsType) = {},
metrics: Metrics = newMetrics(),
logger?: MeasureLogger,
readonly parent?: MeasureContext,
readonly logParams?: ParamsType,
readonly otlpLogger?: Logger,
readonly meter?: MetricsContext
) {
this.name = name
this.params = params
this.fullParams = fullParams
this.metrics = metrics
this.metrics.namedParams = this.metrics.namedParams ?? {}
for (const [k, v] of Object.entries(params)) {
if (this.metrics.namedParams[k] !== v) {
this.metrics.namedParams[k] = v
} else {
this.metrics.namedParams[k] = '*'
}
}
this.logger = logger ?? (this.logParams != null ? consoleLogger(this.logParams ?? {}) : noParamsLogger)
}
measure (name: string, value: number, override?: boolean): void {
const cnt = this.meter?.getCounter(name)
if (cnt !== undefined) {
if (cnt.value !== value) {
cnt.counter.record(value, this.params)
cnt.value = value
}
}
}
newChild (
name: string,
params: ParamsType,
opt?: {
fullParams?: FullParamsType
logger?: MeasureLogger
span?: WithOptions['span'] // By default true
meta?: Record<string, string | number | boolean>
}
): MeasureContext {
let _span: Span | undefined
let childContext: Context | undefined
if (opt?.span === true || opt?.span === 'inherit') {
childContext = opt?.span === 'inherit' ? context.active() : (this.context ?? context.active())
if (opt.meta !== undefined && Object.keys(opt.meta).length > 0) {
// We need to set meta params
childContext = propagation.extract(childContext ?? context.active(), opt.meta)
}
_span = this.tracer.startSpan(name, undefined, childContext)
const spanParams = [...Object.entries(params)]
for (const [k, v] of spanParams) {
_span?.setAttribute(k, v as any)
}
}
if (opt?.span === 'disable') {
childContext = suppressTracing(childContext ?? context.active())
}
if (childContext !== undefined && _span !== undefined) {
childContext = trace.setSpan(childContext, _span)
}
const result = new OpenTelemetryMetricsContext(
name,
this.tracer,
childContext,
_span,
params,
opt?.fullParams ?? {},
childMetrics(this.metrics, [name]),
opt?.logger ?? this.logger,
this,
this.logParams,
this.otlpLogger,
this.meter
)
result.id = this.id
result.contextData = this.contextData
return result
}
extractMeta (): Record<string, string | number | boolean> {
const headers: Record<string, string> = {}
if (this.context !== undefined) {
propagation.inject(this.context, headers)
}
return headers
}
with<T>(
name: string,
params: ParamsType,
op: (ctx: MeasureContext) => T | Promise<T>,
fullParams?: ParamsType | (() => FullParamsType),
opt?: WithOptions
): Promise<T> {
const c = this.newChild(name, opt?.inheritParams === true ? { ...this.params, ...params } : params, {
fullParams,
logger: this.logger,
span: opt?.span ?? true,
meta: opt?.meta
})
let needFinally = true
try {
const _context = (c as OpenTelemetryMetricsContext).context
const span = (c as OpenTelemetryMetricsContext).span
const value = _context !== undefined ? context.with(_context, () => op(c)) : op(c)
if (value instanceof Promise) {
needFinally = false
if (span !== undefined) {
void value.catch((err) => {
span?.recordException(err)
span?.setStatus({
code: SpanStatusCode.ERROR,
message: err.message
})
})
}
return value.finally(() => {
if (span !== undefined) {
const fParams = typeof fullParams === 'function' ? fullParams() : fullParams
const spanParams = [...Object.entries(params), ...Object.entries(fParams ?? {})]
for (const [k, v] of spanParams) {
span?.setAttribute(k, typeof v === 'object' ? JSON.stringify(v) : v)
}
}
c.end()
if (opt?.log === true) {
this.logger.logOperation(name, platformNowDiff((c as OpenTelemetryMetricsContext).st), {
...params,
...fullParams
})
}
})
} else {
if (value == null) {
return nullPromise as Promise<T>
}
return Promise.resolve(value)
}
} finally {
if (needFinally) {
c.end()
}
}
}
withSync<T>(
name: string,
params: ParamsType,
op: (ctx: MeasureContext) => T,
fullParams?: ParamsType | (() => FullParamsType),
opt?: WithOptions
): T {
const c = this.newChild(name, params, { fullParams, logger: this.logger, span: opt?.span ?? true })
const _context = (c as OpenTelemetryMetricsContext).context
try {
return _context !== undefined ? context.with(_context, () => op(c)) : op(c)
} finally {
c.end()
}
}
error (message: string, args?: Record<string, any>): void {
if (this.otlpLogger !== undefined) {
this.otlpLogger.emit({
severityNumber: SeverityNumber.ERROR,
severityText: 'error',
context: this.context,
body: message,
attributes: {
'service.name': sdkServiceName,
...(args ?? {})
}
})
}
this.logger.error(message, { ...this.params, ...args, ...(this.logParams ?? {}) })
}
info (message: string, args?: Record<string, any>): void {
if (this.otlpLogger !== undefined) {
this.otlpLogger.emit({
context: this.context,
severityNumber: SeverityNumber.INFO,
severityText: 'info',
body: message,
attributes: {
'service.name': sdkServiceName,
...(args ?? {})
}
})
}
this.logger.info(message, { ...this.params, ...args, ...(this.logParams ?? {}) })
}
warn (message: string, args?: Record<string, any>): void {
if (this.otlpLogger !== undefined) {
this.otlpLogger.emit({
severityNumber: SeverityNumber.WARN,
severityText: 'warn',
context: this.context,
body: message,
attributes: {
'service.name': sdkServiceName,
...(args ?? {})
}
})
}
this.logger.warn(message, { ...this.params, ...args, ...(this.logParams ?? {}) })
}
end (): void {
this.done()
}
getParams (): ParamsType {
return this.params
}
}
/**
* Parse W3C baggage header format to Record<string, string>
*
* W3C baggage format: "key1=value1,key2=value2;property=value,key3=value3"
* Returns only the key-value pairs, ignoring properties
*/
function parseBaggage (baggageHeader?: string): Record<string, string> {
if (baggageHeader == null || typeof baggageHeader !== 'string') {
return {}
}
const result: Record<string, string> = {}
// Split by comma to get individual baggage members
const members = baggageHeader.split(',')
for (const member of members) {
const trimmedMember = member.trim()
if (trimmedMember === '') continue
// Split by semicolon to separate key=value from properties
const parts = trimmedMember.split(';')
const keyValuePart = parts[0]?.trim()
if (keyValuePart == null) continue
// Split by equals to get key and value
const equalIndex = keyValuePart.indexOf('=')
if (equalIndex === -1) continue
const key = keyValuePart.substring(0, equalIndex).trim()
const value = keyValuePart.substring(equalIndex + 1).trim()
if (key != null) {
// URL decode the key and value
try {
const decodedKey = decodeURIComponent(key)
const decodedValue = decodeURIComponent(value)
result[decodedKey] = decodedValue
} catch (error) {
// If decoding fails, use the original values
result[key] = value
}
}
}
return result
}
let sdk: NodeSDK | undefined
let sdkServiceName: string | undefined
let sdkServiceVersion: string | undefined
let loggerProvider: LoggerProvider | undefined
export function initOpenTelemetrySDK (serviceName: string, version: string): boolean {
if (sdk !== undefined) {
return true
}
process.env.OTEL_SERVICE_NAME = serviceName
process.env.OTEL_SERVICE_VERSION = version
sdkServiceName = serviceName
sdkServiceVersion = version
const tracesUrl = getTracesUrl()
if (tracesUrl === undefined) {
return false
}
const traceHeaders = parseTraceExporterHeaders()
const exporter = new OTLPTraceExporter({
url: tracesUrl,
headers: traceHeaders,
compression:
(process.env.OTEL_EXPORTER_OTLP_COMPRESSION as CompressionAlgorithm) ??
(process.env.OTEL_EXPORTER_OTLP_TRACES_COMPRESSION as CompressionAlgorithm) ??
CompressionAlgorithm.GZIP,
keepAlive: true
})
const batchSpanProcessor = new BatchSpanProcessor(exporter, {
maxExportBatchSize: parseInt(process.env.OTEL_EXPORTER_OTLP_TRACES_MAX_EXPORT_BATCH_SIZE ?? '1000'),
maxQueueSize: parseInt(process.env.OTEL_EXPORTER_OTLP_TRACES_MAX_QUEUE_SIZE ?? '1000')
})
// Logs
const logsEndpoint = getLogsUrl()
const logHeaders = parseLogsExporterHeaders()
const logExporter = new OTLPLogExporter({
url: logsEndpoint,
headers: logHeaders,
compression:
(process.env.OTEL_EXPORTER_OTLP_COMPRESSION as CompressionAlgorithm) ??
(process.env.OTEL_EXPORTER_OTLP_LOGS_COMPRESSION as CompressionAlgorithm) ??
CompressionAlgorithm.GZIP,
keepAlive: true
})
const batchLogProcessor = new BatchLogRecordProcessor(logExporter, {
maxExportBatchSize: parseInt(process.env.OTEL_EXPORTER_OTLP_LOGS_MAX_EXPORT_BATCH_SIZE ?? '1000'),
maxQueueSize: parseInt(process.env.OTEL_EXPORTER_OTLP_LOGS_MAX_QUEUE_SIZE ?? '1000')
})
// Metrics
const metricsUrl = getMetricsUrl()
const metricsHeaders = parseMetricsExporterHeaders()
const metricsExporter = new OTLPMetricExporter({
url: metricsUrl,
headers: metricsHeaders
})
const metricReader = new PeriodicExportingMetricReader({
exporter: metricsExporter,
exportIntervalMillis: 15000
})
// SDK
sdk = new NodeSDK({
spanProcessors: [batchSpanProcessor],
serviceName,
traceExporter: exporter,
resource: resourceFromAttributes({
'service-name': serviceName,
'service-version': version ?? '0.7',
'deployment-environment': process.env.OTEL_ENVIRONMENT
}),
instrumentations: [getNodeAutoInstrumentations()],
idGenerator: new AWSXRayIdGenerator(),
logRecordProcessors: [batchLogProcessor],
metricReader
})
sdk.start()
loggerProvider = new LoggerProvider({
processors: [batchLogProcessor]
})
// Graceful shutdown
process.on('SIGTERM', () => {
sdk
?.shutdown()
.then(() => {
console.log('Tracing terminated')
})
.catch((error) => {
console.log('Error terminating tracing', error)
})
.finally(() => process.exit(0))
})
console.log('Using open telemetry metrics context', {
traceEndpoint: tracesUrl,
tracerHeadersSet: Array.from(Object.keys(traceHeaders)),
logsEndpoint,
logHeadersSet: Array.from(Object.keys(logHeaders))
})
return true
}
export function reportOTELError (error: Error, attributes?: Record<string, any>): void {
if (sdkServiceName !== undefined && sdkServiceVersion !== undefined && loggerProvider !== undefined) {
const otlpLogger = loggerProvider?.getLogger(sdkServiceName, sdkServiceVersion)
otlpLogger?.emit({
severityNumber: SeverityNumber.ERROR,
severityText: 'error',
body: error.message,
context: context.active(),
attributes: {
...attributes,
'service.name': sdkServiceName,
'service.version': sdkServiceVersion,
'error.stack': error.stack
}
})
}
}
export function reportOTEL (
severity: 'info' | 'warning',
message: string,
time: number,
attributes?: Record<string, any>
): void {
if (sdkServiceName !== undefined && sdkServiceVersion !== undefined && loggerProvider !== undefined) {
const otlpLogger = loggerProvider?.getLogger(sdkServiceName, sdkServiceVersion)
otlpLogger?.emit({
severityNumber: severity === 'info' ? SeverityNumber.INFO : SeverityNumber.WARN,
body: message,
severityText: severity,
timestamp: time,
context: context.active(),
attributes: {
...attributes,
'service.name': sdkServiceName,
'service.version': sdkServiceVersion
}
})
}
}
export function createOpenTelemetryMetricsContext (
name: string,
params: ParamsType,
fullParams: FullParamsType | (() => FullParamsType) = {},
metrics: Metrics = newMetrics(),
logger?: MeasureLogger,
version?: string
): MeasureContext {
if (!initOpenTelemetrySDK(name, version ?? '')) {
console.warn('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is not set, OpenTelemetry metrics will not be sent')
return new MeasureMetricsContext(name, params, fullParams, metrics, logger)
}
// Traces
const tracer = trace.getTracer(name)
const otlpLogger =
process.env.OTEL_LOGGER_ENABLED === 'true' ? loggerProvider?.getLogger(sdkServiceName ?? name, version) : undefined
const meter = otelMetrics.getMeter(name, version)
const ctx = new OpenTelemetryMetricsContext(
name,
tracer,
undefined,
undefined,
params,
fullParams,
metrics,
logger,
undefined,
undefined,
otlpLogger,
new MetricsContext(meter)
)
return ctx
}
function parseTraceExporterHeaders (): Record<string, string> {
const headers: Record<string, string> = parseBaggage(process.env.OTEL_EXPORTER_OTLP_HEADERS) ?? {}
if (process.env.OTEL_EXPORTER_OTLP_TRACES_HEADERS !== undefined) {
const extraHeaders = parseBaggage(process.env.OTEL_EXPORTER_OTLP_TRACES_HEADERS)
for (const [key, value] of Object.entries(extraHeaders)) {
headers[key] = value
}
}
return headers
}
function getTracesUrl (): string | undefined {
let tracesUrl = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT
if (tracesUrl !== undefined && !tracesUrl.endsWith('/v1/traces')) {
if (tracesUrl.endsWith('/')) {
tracesUrl += 'v1/traces'
} else {
tracesUrl += '/v1/traces'
}
}
return tracesUrl
}
function getLogsUrl (): string | undefined {
let logsUrl = process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT
if (logsUrl !== undefined && !logsUrl.endsWith('/v1/logs')) {
if (logsUrl.endsWith('/')) {
logsUrl += 'v1/logs'
} else {
logsUrl += '/v1/logs'
}
}
return logsUrl
}
function parseLogsExporterHeaders (): Record<string, string> {
const headers: Record<string, string> = parseBaggage(process.env.OTEL_EXPORTER_OTLP_HEADERS) ?? {}
if (process.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS !== undefined) {
const extraHeaders = parseBaggage(process.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS)
for (const [key, value] of Object.entries(extraHeaders)) {
headers[key] = value
}
}
return headers
}
function getMetricsUrl (): string | undefined {
let metricsUrl = process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT
if (metricsUrl !== undefined && !metricsUrl.endsWith('/v1/metrics')) {
if (metricsUrl.endsWith('/')) {
metricsUrl += 'v1/metrics'
} else {
metricsUrl += '/v1/metrics'
}
}
return metricsUrl
}
function parseMetricsExporterHeaders (): Record<string, string> {
const headers: Record<string, string> = parseBaggage(process.env.OTEL_EXPORTER_OTLP_HEADERS) ?? {}
if (process.env.OTEL_EXPORTER_OTLP_METRICS_HEADERS !== undefined) {
const extraHeaders = parseBaggage(process.env.OTEL_EXPORTER_OTLP_METRICS_HEADERS)
for (const [key, value] of Object.entries(extraHeaders)) {
headers[key] = value
}
}
return headers
}
-12
View File
@@ -1,12 +0,0 @@
{
"extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"declarationDir": "./types",
"tsBuildInfoFile": ".build/build.tsbuildinfo"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "lib", "dist", "types", "bundle"]
}
-7
View File
@@ -1,7 +0,0 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
-8
View File
@@ -1,8 +0,0 @@
*
!/lib/**
!/types/**
!/src/**
!CHANGELOG.md
/lib/**/__test__/
/types/**/__test__/
/src/**/__test__/
-73
View File
@@ -1,73 +0,0 @@
{
"name": "@hcengineering/measurements",
"entries": [
{
"version": "0.7.13",
"tag": "@hcengineering/measurements_v0.7.13",
"date": "Tue, 14 Oct 2025 03:02:36 GMT",
"comments": {
"patch": [
{
"comment": "Remove zero value measure with override values"
}
],
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.18` to `0.7.19`"
}
]
}
},
{
"version": "0.7.12",
"tag": "@hcengineering/measurements_v0.7.12",
"date": "Sat, 11 Oct 2025 19:18:56 GMT",
"comments": {
"patch": [
{
"comment": "rollback eslint"
}
],
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.14` to `0.7.15`"
}
]
}
},
{
"version": "0.7.11",
"tag": "@hcengineering/measurements_v0.7.11",
"date": "Sat, 11 Oct 2025 17:58:53 GMT",
"comments": {
"none": [
{
"comment": "Add tests"
}
],
"patch": [
{
"comment": "Fix eslint deps"
}
],
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.12` to `0.7.13`"
}
]
}
},
{
"version": "0.7.10",
"tag": "@hcengineering/measurements_v0.7.10",
"date": "Fri, 10 Oct 2025 12:32:59 GMT",
"comments": {
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.10` to `0.7.11`"
}
]
}
}
]
}
-30
View File
@@ -1,30 +0,0 @@
# Change Log - @hcengineering/measurements
This log was last generated on Tue, 14 Oct 2025 03:02:36 GMT and should not be manually modified.
## 0.7.13
Tue, 14 Oct 2025 03:02:36 GMT
### Patches
- Remove zero value measure with override values
## 0.7.12
Sat, 11 Oct 2025 19:18:56 GMT
### Patches
- rollback eslint
## 0.7.11
Sat, 11 Oct 2025 17:58:53 GMT
### Patches
- Fix eslint deps
## 0.7.10
Fri, 10 Oct 2025 12:32:59 GMT
_Initial release_
-4
View File
@@ -1,4 +0,0 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig"
}
-7
View File
@@ -1,7 +0,0 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ["./src"],
coverageReporters: ["text-summary", "html"]
}
-54
View File
@@ -1,54 +0,0 @@
{
"name": "@hcengineering/measurements",
"version": "0.7.13",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
"files": [
"lib/**/*",
"!lib/**/__test__/**",
"types/**/*",
"!types/**/__test__/**",
"src/**/*",
"!src/**/__test__/**",
"README.md",
"CHANGELOG.md"
],
"author": "Anticrm Platform Contributors",
"license": "EPL-2.0",
"scripts": {
"build": "compile",
"build:watch": "compile",
"test": "jest --passWithNoTests --silent",
"format": "format src",
"_phase:build": "compile transpile src",
"_phase:test": "jest --passWithNoTests --silent",
"_phase:format": "format src",
"_phase:validate": "compile validate"
},
"devDependencies": {
"@hcengineering/platform-rig": "workspace:^0.7.19",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint": "^8.54.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-n": "^15.4.0"
},
"repository": "https://github.com/hcengineering/platform",
"exports": {
".": {
"types": "./types/index.d.ts",
"require": "./lib/index.js",
"import": "./lib/index.js"
}
},
"publishConfig": {
"access": "public"
}
}
@@ -1,467 +0,0 @@
import {
MeasureMetricsContext,
NoMetricsContext,
consoleLogger,
noParamsLogger,
withContext,
setOperationLogProfiling,
registerOperationLog,
updateOperationLog,
addOperation
} from '../context'
import { newMetrics } from '../metrics'
import type { MeasureLogger, MeasureContext } from '../types'
describe('context', () => {
describe('consoleLogger', () => {
it('should create a logger with params', () => {
const logger = consoleLogger({ service: 'test' })
expect(logger).toBeDefined()
expect(typeof logger.info).toBe('function')
expect(typeof logger.error).toBe('function')
expect(typeof logger.warn).toBe('function')
expect(typeof logger.close).toBe('function')
})
it('should log info messages', () => {
const consoleSpy = jest.spyOn(console, 'info').mockImplementation()
const logger = consoleLogger({ service: 'test' })
logger.info('Test message', { key: 'value' })
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('should log error messages', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
const logger = consoleLogger({ service: 'test' })
logger.error('Error message', { error: 'details' })
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('should log warn messages', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
const logger = consoleLogger({ service: 'test' })
logger.warn('Warning message', { warning: 'info' })
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('should handle errors in params', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
const logger = consoleLogger({})
const error = new Error('Test error')
logger.error('Error occurred', { error })
expect(consoleSpy).toHaveBeenCalled()
const call = consoleSpy.mock.calls[0]
expect(call[0]).toBe('Error occurred')
consoleSpy.mockRestore()
})
})
describe('MeasureMetricsContext', () => {
let logger: MeasureLogger
beforeEach(() => {
logger = noParamsLogger
})
it('should create a new context', () => {
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('test', { op: 'create' }, {}, metrics, logger)
expect(ctx).toBeDefined()
expect(ctx.metrics).toBe(metrics)
expect(ctx.logger).toBe(logger)
})
it('should measure operation duration', async () => {
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('test', { op: 'create' }, {}, metrics, logger)
await new Promise((resolve) => setTimeout(resolve, 50))
ctx.end()
expect(metrics.operations).toBe(1)
expect(metrics.value).toBeGreaterThan(40)
})
it('should create child context', () => {
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('parent', { op: 'parent' }, {}, metrics, logger)
const child = ctx.newChild('child', { op: 'child' })
expect(child).toBeDefined()
expect(child.parent).toBe(ctx)
})
it('should execute async operation with context', async () => {
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('test', {}, {}, metrics, logger)
let executed = false
await ctx.with('operation', { op: 'test' }, async () => {
executed = true
await new Promise((resolve) => setTimeout(resolve, 10))
})
expect(executed).toBe(true)
expect(metrics.measurements.operation).toBeDefined()
})
it('should execute sync operation with context', () => {
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('test', {}, {}, metrics, logger)
const result = ctx.withSync('operation', { op: 'test' }, () => {
return 42
})
expect(result).toBe(42)
expect(metrics.measurements.operation).toBeDefined()
})
it('should measure custom value', () => {
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('test', {}, {}, metrics, logger)
ctx.measure('custom', 100)
expect(metrics.measurements['#custom']).toBeDefined()
expect(metrics.measurements['#custom'].value).toBe(100)
})
it('should log info messages', () => {
const mockLogger: MeasureLogger = {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
close: jest.fn(async () => {}),
logOperation: jest.fn()
}
const ctx = new MeasureMetricsContext('test', { op: 'test' }, {}, newMetrics(), mockLogger)
ctx.info('Test message', { key: 'value' })
expect(mockLogger.info).toHaveBeenCalledWith(
'Test message',
expect.objectContaining({ key: 'value', op: 'test' })
)
})
it('should log error messages', () => {
const mockLogger: MeasureLogger = {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
close: jest.fn(async () => {}),
logOperation: jest.fn()
}
const ctx = new MeasureMetricsContext('test', { op: 'test' }, {}, newMetrics(), mockLogger)
ctx.error('Error message', { error: 'details' })
expect(mockLogger.error).toHaveBeenCalledWith(
'Error message',
expect.objectContaining({ error: 'details', op: 'test' })
)
})
it('should log warn messages', () => {
const mockLogger: MeasureLogger = {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
close: jest.fn(async () => {}),
logOperation: jest.fn()
}
const ctx = new MeasureMetricsContext('test', { op: 'test' }, {}, newMetrics(), mockLogger)
ctx.warn('Warning message', { warning: 'info' })
expect(mockLogger.warn).toHaveBeenCalledWith(
'Warning message',
expect.objectContaining({ warning: 'info', op: 'test' })
)
})
it('should get params', () => {
const ctx = new MeasureMetricsContext('test', { op: 'test', method: 'GET' }, {}, newMetrics(), logger)
const params = ctx.getParams()
expect(params).toEqual({ op: 'test', method: 'GET' })
})
it('should share contextData with children', () => {
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('parent', {}, {}, metrics, logger)
ctx.contextData = { userId: '123' }
const child = ctx.newChild('child', {})
expect(child.contextData).toBe(ctx.contextData)
})
it('should handle named parameters', () => {
const metrics = newMetrics()
const r = new MeasureMetricsContext('test', { method: 'GET' }, {}, metrics, logger)
expect(r).toBeDefined()
expect(metrics.namedParams.method).toBe('GET')
})
it('should update named parameters on multiple contexts', () => {
const metrics = newMetrics()
const ctx1 = new MeasureMetricsContext('test1', { method: 'GET' }, {}, metrics, logger)
expect(ctx1).toBeDefined()
expect(metrics.namedParams.method).toBe('GET')
// Create another context with different value for same param
const ctx2 = new MeasureMetricsContext('test2', { method: 'POST' }, {}, metrics, logger)
expect(ctx2).toBeDefined()
// The second context will see existing value is different, so it will update to '*'
// But this happens within the constructor logic
expect(metrics.namedParams.method).toBe('POST')
})
})
describe('NoMetricsContext', () => {
it('should create a no-op context', () => {
const ctx = new NoMetricsContext()
expect(ctx).toBeDefined()
expect(ctx.logger).toBeDefined()
})
it('should execute operations without measuring', async () => {
const ctx = new NoMetricsContext()
let executed = false
await ctx.with('operation', {}, async () => {
executed = true
})
expect(executed).toBe(true)
})
it('should create child contexts', () => {
const ctx = new NoMetricsContext()
const child = ctx.newChild('child', {})
expect(child).toBeDefined()
expect(child).toBeInstanceOf(NoMetricsContext)
})
it('should handle measure calls without error', () => {
const ctx = new NoMetricsContext()
expect(() => {
ctx.measure('test', 100)
}).not.toThrow()
})
it('should handle end calls without error', () => {
const ctx = new NoMetricsContext()
expect(() => {
ctx.end()
}).not.toThrow()
})
it('should return empty params', () => {
const ctx = new NoMetricsContext()
const params = ctx.getParams()
expect(params).toEqual({})
})
})
describe('withContext decorator', () => {
it('should wrap method with context', async () => {
class TestClass {
@withContext('testOperation', { service: 'test' })
async testMethod (ctx: MeasureContext, value: number): Promise<number> {
return value * 2
}
}
const instance = new TestClass()
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('root', {}, {}, metrics, noParamsLogger)
const result = await instance.testMethod(ctx, 21)
expect(result).toBe(42)
expect(metrics.measurements.testOperation).toBeDefined()
})
})
describe('operation log profiling', () => {
beforeEach(() => {
setOperationLogProfiling(false)
})
afterEach(() => {
setOperationLogProfiling(false)
})
it('should register operation log when profiling enabled', () => {
setOperationLogProfiling(true)
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('test', {}, {}, metrics, noParamsLogger)
const { opLogMetrics, op } = registerOperationLog(ctx)
expect(opLogMetrics).toBe(metrics)
expect(op).toBeDefined()
expect(ctx.id).toBeDefined()
})
it('should not register operation log when profiling disabled', () => {
setOperationLogProfiling(false)
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('test', {}, {}, metrics, noParamsLogger)
const { opLogMetrics, op } = registerOperationLog(ctx)
expect(opLogMetrics).toBeUndefined()
expect(op).toBeUndefined()
})
it('should update operation log', () => {
setOperationLogProfiling(true)
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('test', {}, {}, metrics, noParamsLogger)
const { opLogMetrics, op } = registerOperationLog(ctx)
updateOperationLog(opLogMetrics, op)
expect(op?.end).toBeGreaterThan(0)
})
it('should add operation to log', async () => {
setOperationLogProfiling(true)
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('test', {}, {}, metrics, noParamsLogger)
registerOperationLog(ctx)
let executed = false
await addOperation(ctx, 'asyncOp', { op: 'test' }, async () => {
executed = true
})
expect(executed).toBe(true)
expect(metrics.opLog).toBeDefined()
})
it('should limit operation log entries', () => {
setOperationLogProfiling(true)
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('test', {}, {}, metrics, noParamsLogger)
registerOperationLog(ctx)
// Create many operation log entries
if (metrics.opLog != null && ctx.id != null) {
for (let i = 0; i < 50; i++) {
metrics.opLog[ctx.id].ops.push({
op: `test${i}`,
start: i,
end: i + 1,
params: {}
})
}
const op = metrics.opLog[ctx.id]
updateOperationLog(metrics, op)
expect(Object.keys(metrics.opLog).length).toBeLessThanOrEqual(31)
}
})
})
describe('extractMeta', () => {
it('should return empty metadata', () => {
const ctx = new MeasureMetricsContext('test', {}, {}, newMetrics(), noParamsLogger)
const meta = ctx.extractMeta()
expect(meta).toEqual({})
})
})
describe('async operations', () => {
it('should handle promise rejection', async () => {
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('test', {}, {}, metrics, noParamsLogger)
await expect(
ctx.with('operation', { op: 'test' }, async () => {
throw new Error('Test error')
})
).rejects.toThrow('Test error')
expect(metrics.measurements.operation).toBeDefined()
})
it('should return null promise for null sync result', async () => {
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('test', {}, {}, metrics, noParamsLogger)
const result = await ctx.with('operation', { op: 'test' }, () => {
return null
})
expect(result).toBeUndefined()
})
it('should handle sync return value', async () => {
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('test', {}, {}, metrics, noParamsLogger)
const result = await ctx.with('operation', { op: 'test' }, () => {
return 42
})
expect(result).toBe(42)
})
it('should log operation when log option is true', async () => {
const mockLogger: MeasureLogger = {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
close: jest.fn(async () => {}),
logOperation: jest.fn()
}
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('test', {}, {}, metrics, mockLogger)
await ctx.with(
'operation',
{ op: 'test' },
async () => {
await new Promise((resolve) => setTimeout(resolve, 10))
},
{},
{ log: true }
)
expect(mockLogger.logOperation).toHaveBeenCalledWith(
'operation',
expect.any(Number),
expect.objectContaining({ op: 'test' })
)
})
})
})
@@ -1,47 +0,0 @@
import { platformNow, platformNowDiff } from '../index'
describe('index', () => {
describe('platformNow', () => {
it('should return a positive number', () => {
const now = platformNow()
expect(typeof now).toBe('number')
expect(now).toBeGreaterThan(0)
})
it('should return increasing values over time', async () => {
const first = platformNow()
await new Promise((resolve) => setTimeout(resolve, 10))
const second = platformNow()
expect(second).toBeGreaterThan(first)
})
})
describe('platformNowDiff', () => {
it('should calculate the difference between timestamps', async () => {
const start = platformNow()
await new Promise((resolve) => setTimeout(resolve, 50))
const diff = platformNowDiff(start)
expect(diff).toBeGreaterThan(40)
expect(diff).toBeLessThan(100)
})
it('should round to 2 decimal places', async () => {
const start = platformNow()
await new Promise((resolve) => setTimeout(resolve, 10))
const diff = platformNowDiff(start)
// Check that it has at most 2 decimal places
const decimalPlaces = (diff.toString().split('.')[1] ?? '').length
expect(decimalPlaces).toBeLessThanOrEqual(2)
})
it('should handle very small differences', () => {
const start = platformNow()
const diff = platformNowDiff(start)
expect(diff).toBeGreaterThanOrEqual(0)
expect(typeof diff).toBe('number')
})
})
})
@@ -1,297 +0,0 @@
import {
newMetrics,
measure,
childMetrics,
metricsAggregate,
metricsToString,
metricsToJson,
metricsToRows,
updateMeasure
} from '../metrics'
import { platformNow } from '../index'
describe('metrics', () => {
describe('newMetrics', () => {
it('should create a new metrics object with default values', () => {
const metrics = newMetrics()
expect(metrics.operations).toBe(0)
expect(metrics.value).toBe(0)
expect(metrics.measurements).toEqual({})
expect(metrics.params).toEqual({})
expect(metrics.namedParams).toEqual({})
})
})
describe('measure', () => {
it('should measure operation duration', async () => {
const metrics = newMetrics()
const done = measure(metrics, { operation: 'test' })
await new Promise((resolve) => setTimeout(resolve, 50))
done()
expect(metrics.operations).toBe(1)
expect(metrics.value).toBeGreaterThan(40)
})
it('should call endOp callback with duration', async () => {
const metrics = newMetrics()
let capturedSpend = 0
const done = measure(metrics, { operation: 'test' }, {}, (spend) => {
capturedSpend = spend
})
await new Promise((resolve) => setTimeout(resolve, 50))
done()
expect(capturedSpend).toBeGreaterThan(40)
})
it('should handle multiple operations', async () => {
const metrics = newMetrics()
const done1 = measure(metrics, { operation: 'test' })
await new Promise((resolve) => setTimeout(resolve, 20))
done1()
const done2 = measure(metrics, { operation: 'test' })
await new Promise((resolve) => setTimeout(resolve, 20))
done2()
expect(metrics.operations).toBe(2)
expect(metrics.value).toBeGreaterThan(30)
})
})
describe('updateMeasure', () => {
it('should update metrics with custom value', () => {
const metrics = newMetrics()
const st = platformNow()
updateMeasure(metrics, st, { op: 'test' }, {}, undefined, 100)
expect(metrics.operations).toBe(1)
expect(metrics.value).toBe(100)
})
it('should override operations when override is true', () => {
const metrics = newMetrics()
const st = platformNow()
// First call without override - accumulates value and increments operations
updateMeasure(metrics, st, { op: 'test' }, {}, undefined, 50, false)
expect(metrics.value).toBe(50)
expect(metrics.operations).toBe(1)
// Second call with override - sets operations, doesn't add to value
updateMeasure(metrics, st, { op: 'test' }, {}, undefined, 100, true)
expect(metrics.operations).toBe(100) // overridden
expect(metrics.value).toBe(50) // not changed when override=true
})
it('should track parameters', () => {
const metrics = newMetrics()
const st = platformNow()
updateMeasure(metrics, st, { method: 'GET' }, {}, undefined, 100)
updateMeasure(metrics, st, { method: 'POST' }, {}, undefined, 200)
expect(metrics.params.method).toBeDefined()
expect(metrics.params.method.GET).toBeDefined()
expect(metrics.params.method.POST).toBeDefined()
expect(metrics.params.method.GET.value).toBe(100)
expect(metrics.params.method.POST.value).toBe(200)
})
it('should handle multiple parameters as counters', () => {
const metrics = newMetrics()
const st = platformNow()
updateMeasure(metrics, st, { method: 'GET', status: '200' }, {}, undefined, 100)
updateMeasure(metrics, st, { method: 'GET', status: '404' }, {}, undefined, 50)
expect(metrics.params.method.GET).toBeDefined()
expect(metrics.params.method.GET.topResult).toBeDefined()
expect(metrics.params.method.GET.topResult?.length).toBeGreaterThan(0)
})
it('should update top results', () => {
const metrics = newMetrics()
const st = platformNow()
updateMeasure(metrics, st, {}, { request: 'slow' }, undefined, 100)
updateMeasure(metrics, st, {}, { request: 'fast' }, undefined, 10)
expect(metrics.topResult).toBeDefined()
expect(metrics.topResult?.length).toBeGreaterThan(0)
})
})
describe('childMetrics', () => {
it('should create child metrics in hierarchy', () => {
const root = newMetrics()
const child = childMetrics(root, ['level1', 'level2'])
expect(root.measurements.level1).toBeDefined()
expect(root.measurements.level1.measurements.level2).toBeDefined()
expect(child).toBe(root.measurements.level1.measurements.level2)
})
it('should reuse existing child metrics', () => {
const root = newMetrics()
const child1 = childMetrics(root, ['level1'])
const child2 = childMetrics(root, ['level1'])
expect(child1).toBe(child2)
})
it('should create nested paths', () => {
const root = newMetrics()
childMetrics(root, ['api', 'users', 'create'])
expect(root.measurements.api).toBeDefined()
expect(root.measurements.api.measurements.users).toBeDefined()
expect(root.measurements.api.measurements.users.measurements.create).toBeDefined()
})
})
describe('metricsAggregate', () => {
it('should aggregate metrics', () => {
const metrics = newMetrics()
metrics.value = 100
metrics.operations = 10
const child1 = childMetrics(metrics, ['child1'])
child1.value = 50
child1.operations = 5
const child2 = childMetrics(metrics, ['child2'])
child2.value = 30
child2.operations = 3
const aggregated = metricsAggregate(metrics)
expect(aggregated.value).toBe(80) // child1 + child2
expect(aggregated.operations).toBe(10)
})
it('should limit number of child metrics', () => {
const metrics = newMetrics()
for (let i = 0; i < 10; i++) {
const child = childMetrics(metrics, [`child${i}`])
child.value = i * 10
}
const aggregated = metricsAggregate(metrics, 3)
expect(Object.keys(aggregated.measurements).length).toBe(3)
})
it('should filter out metrics starting with #', () => {
const metrics = newMetrics()
const child1 = childMetrics(metrics, ['normal'])
child1.value = 50
const child2 = childMetrics(metrics, ['#internal'])
child2.value = 30
const aggregated = metricsAggregate(metrics)
expect(aggregated.value).toBe(50) // only 'normal' counted
})
})
describe('metricsToString', () => {
it('should convert metrics to string', () => {
const metrics = newMetrics()
metrics.value = 100
metrics.operations = 10
const str = metricsToString(metrics, 'TestMetrics', 50)
expect(str).toContain('TestMetrics')
expect(str).toContain('100')
expect(str).toContain('10')
})
it('should include child metrics in string', () => {
const metrics = newMetrics()
const child = childMetrics(metrics, ['operation'])
child.value = 50
child.operations = 5
const str = metricsToString(metrics, 'TestMetrics', 50)
expect(str).toContain('operation')
})
})
describe('metricsToJson', () => {
it('should convert metrics to JSON', () => {
const metrics = newMetrics()
metrics.value = 100
metrics.operations = 10
const json = metricsToJson(metrics)
// aggregated value is the total value when no children
expect(json.$total).toBe(100)
expect(json.$ops).toBe(10)
})
it('should include child metrics in JSON', () => {
const metrics = newMetrics()
const child = childMetrics(metrics, ['operation'])
child.value = 50
child.operations = 5
const json = metricsToJson(metrics)
expect(json).toBeDefined()
const keys = Object.keys(json)
expect(keys.some((k) => k.includes('operation'))).toBe(true)
})
})
describe('metricsToRows', () => {
it('should convert metrics to rows', () => {
const metrics = newMetrics()
metrics.value = 100
metrics.operations = 10
const rows = metricsToRows(metrics, 'TestMetrics')
expect(Array.isArray(rows)).toBe(true)
expect(rows.length).toBeGreaterThan(0)
expect(rows[0]).toContain('TestMetrics')
})
it('should include child metrics in rows', () => {
const metrics = newMetrics()
const child = childMetrics(metrics, ['operation'])
child.value = 50
child.operations = 5
const rows = metricsToRows(metrics, 'TestMetrics')
expect(rows.length).toBeGreaterThan(1)
expect(rows.some((row) => row.includes('operation'))).toBe(true)
})
it('should properly format row values', () => {
const metrics = newMetrics()
metrics.value = 100
metrics.operations = 10
const rows = metricsToRows(metrics, 'TestMetrics')
expect(rows[0]).toHaveLength(5) // offset, name, avg, total, ops
expect(typeof rows[0][0]).toBe('number') // offset
expect(typeof rows[0][1]).toBe('string') // name
})
})
})
@@ -1,260 +0,0 @@
import { MeasureMetricsContext, NoMetricsContext, noParamsLogger } from '../context'
import { newMetrics, metricsAggregate } from '../metrics'
import type { MeasureContext } from '../types'
describe('performance', () => {
describe('overhead measurement', () => {
// Reduced iterations to fit within 10 seconds total test time
const iterations = 100
const depth = 5
it('should measure overhead of with() vs raw execution', async () => {
// Baseline: raw execution without measurement
const baselineStart = performance.now()
for (let i = 0; i < iterations; i++) {
await simulateWork(1)
}
const baselineTime = performance.now() - baselineStart
// With measurement context
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('root', {}, {}, metrics, noParamsLogger)
const measuredStart = performance.now()
for (let i = 0; i < iterations; i++) {
await ctx.with('operation', { iteration: i }, async () => {
await simulateWork(1)
})
}
const measuredTime = performance.now() - measuredStart
const overhead = measuredTime - baselineTime
const overheadPercentage = (overhead / baselineTime) * 100
console.log(`\n📊 Overhead Analysis (${iterations} iterations):`)
console.log(` Baseline time: ${baselineTime.toFixed(2)}ms`)
console.log(` Measured time: ${measuredTime.toFixed(2)}ms`)
console.log(` Overhead: ${overhead.toFixed(2)}ms (${overheadPercentage.toFixed(2)}%)`)
console.log(` Per operation: ${(overhead / iterations).toFixed(4)}ms`)
expect(metrics.measurements.operation).toBeDefined()
expect(metrics.measurements.operation.operations).toBe(iterations)
// Overhead should be reasonable (typically < 50% for simple operations)
// This is informational rather than a strict assertion
expect(overheadPercentage).toBeLessThan(200)
})
it('should measure overhead with deep nested contexts', async () => {
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('root', {}, {}, metrics, noParamsLogger)
// Baseline: single level
const singleLevelStart = performance.now()
for (let i = 0; i < iterations; i++) {
await ctx.with('shallow', {}, async () => {
await simulateWork(1)
})
}
const singleLevelTime = performance.now() - singleLevelStart
// Deep nesting
const deepStart = performance.now()
for (let i = 0; i < iterations; i++) {
await deepNestedExecution(ctx, depth, 1)
}
const deepTime = performance.now() - deepStart
const nestingOverhead = deepTime - singleLevelTime
const overheadPerLevel = nestingOverhead / (iterations * depth)
console.log(`\n📊 Deep Nesting Overhead (${iterations} iterations, depth=${depth}):`)
console.log(` Single level: ${singleLevelTime.toFixed(2)}ms`)
console.log(` Deep nested: ${deepTime.toFixed(2)}ms`)
console.log(` Nesting overhead: ${nestingOverhead.toFixed(2)}ms`)
console.log(` Per level: ${overheadPerLevel.toFixed(4)}ms`)
expect(metrics.measurements.shallow).toBeDefined()
expect(metrics.measurements.level0).toBeDefined()
})
it('should measure overhead with complex parameter tracking', async () => {
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('root', {}, {}, metrics, noParamsLogger)
// Simple params
const simpleStart = performance.now()
for (let i = 0; i < iterations; i++) {
await ctx.with('simple', { id: i }, async () => {
await simulateWork(1)
})
}
const simpleTime = performance.now() - simpleStart
// Complex params with multiple tracked values
const complexStart = performance.now()
for (let i = 0; i < iterations; i++) {
await ctx.with(
'complex',
{
method: i % 3 === 0 ? 'GET' : i % 3 === 1 ? 'POST' : 'PUT',
status: i % 2 === 0 ? 200 : 404,
cached: i % 4 === 0
},
async () => {
await simulateWork(1)
},
{
userId: `user_${i % 10}`,
endpoint: `/api/v1/resource/${i % 5}`,
timestamp: Date.now()
}
)
}
const complexTime = performance.now() - complexStart
const paramOverhead = complexTime - simpleTime
console.log(`\n📊 Parameter Tracking Overhead (${iterations} iterations):`)
console.log(` Simple params: ${simpleTime.toFixed(2)}ms`)
console.log(` Complex params: ${complexTime.toFixed(2)}ms`)
console.log(` Param overhead: ${paramOverhead.toFixed(2)}ms`)
console.log(` Per operation: ${(paramOverhead / iterations).toFixed(4)}ms`)
expect(metrics.measurements.simple).toBeDefined()
expect(metrics.measurements.complex).toBeDefined()
expect(Object.keys(metrics.measurements.complex.params).length).toBeGreaterThan(0)
})
it('should measure overhead with NoMetricsContext', async () => {
// NoMetricsContext should have minimal overhead
const noMetricsCtx = new NoMetricsContext(noParamsLogger)
const start = performance.now()
for (let i = 0; i < iterations; i++) {
await noMetricsCtx.with('operation', { iteration: i }, async () => {
await simulateWork(1)
})
}
const noMetricsTime = performance.now() - start
// Compare with raw execution
const rawStart = performance.now()
for (let i = 0; i < iterations; i++) {
await simulateWork(1)
}
const rawTime = performance.now() - rawStart
const overhead = noMetricsTime - rawTime
const overheadPercentage = (overhead / rawTime) * 100
console.log(`\n📊 NoMetricsContext Overhead (${iterations} iterations):`)
console.log(` Raw time: ${rawTime.toFixed(2)}ms`)
console.log(` NoMetrics time: ${noMetricsTime.toFixed(2)}ms`)
console.log(` Overhead: ${overhead.toFixed(2)}ms (${overheadPercentage.toFixed(2)}%)`)
// NoMetricsContext should have very low overhead
expect(overheadPercentage).toBeLessThan(50)
})
})
describe('realistic workload simulation', () => {
it('should measure overhead in complex realistic scenario', async () => {
const metrics = newMetrics()
const ctx = new MeasureMetricsContext('app', { service: 'api' }, {}, metrics, noParamsLogger)
const requests = 50
const baselineStart = performance.now()
// Simulate without metrics
for (let i = 0; i < requests; i++) {
await simulateAPIRequest(null, i)
}
const baselineTime = performance.now() - baselineStart
// Reset for measured run
const measuredStart = performance.now()
// Simulate with metrics
for (let i = 0; i < requests; i++) {
await simulateAPIRequest(ctx, i)
}
const measuredTime = performance.now() - measuredStart
const overhead = measuredTime - baselineTime
const overheadPercentage = (overhead / baselineTime) * 100
console.log(`\n📊 Realistic Workload Analysis (${requests} API requests):`)
console.log(` Baseline: ${baselineTime.toFixed(2)}ms`)
console.log(` With metrics: ${measuredTime.toFixed(2)}ms`)
console.log(` Overhead: ${overhead.toFixed(2)}ms (${overheadPercentage.toFixed(2)}%)`)
console.log(` Per request: ${(overhead / requests).toFixed(4)}ms`)
// Check metrics structure
const aggregated = metricsAggregate(metrics, 10)
console.log(` Collected operations: ${aggregated.measurements.request?.operations ?? 0}`)
expect(aggregated.measurements.request).toBeDefined()
expect(overheadPercentage).toBeLessThan(100) // Should be less than 100% overhead
})
})
})
// Helper functions
async function simulateWork (durationMs: number): Promise<void> {
const end = performance.now() + durationMs
while (performance.now() < end) {
// Busy wait to simulate work
const r = Math.random() * Math.random()
expect(r).toBeGreaterThanOrEqual(0)
}
}
async function deepNestedExecution (
ctx: MeasureContext,
depth: number,
workMs: number,
currentLevel: number = 0
): Promise<void> {
if (currentLevel >= depth) {
await simulateWork(workMs)
return
}
await ctx.with(`level${currentLevel}`, { level: currentLevel }, async (childCtx) => {
await deepNestedExecution(childCtx, depth, workMs, currentLevel + 1)
})
}
async function simulateAPIRequest (ctx: MeasureContext | null, requestId: number): Promise<void> {
const method = ['GET', 'POST', 'PUT'][requestId % 3]
const endpoint = `/api/resource/${requestId % 10}`
if (ctx === null) {
// No metrics version
await simulateWork(1)
// Simulate DB query
await simulateWork(2)
// Simulate processing
await simulateWork(1)
return
}
// With metrics version
await ctx.with('request', { method, endpoint }, async (reqCtx) => {
await reqCtx.with('auth', { userId: `user_${requestId % 50}` }, async () => {
await simulateWork(1)
})
await reqCtx.with('database', { query: 'SELECT' }, async (dbCtx) => {
await dbCtx.with('query_execution', {}, async () => {
await simulateWork(2)
})
})
await reqCtx.with('processing', { items: requestId % 20 }, async () => {
await simulateWork(1)
})
})
}
-386
View File
@@ -1,386 +0,0 @@
// Basic performance metrics suite.
import { platformNow, platformNowDiff } from '.'
import { childMetrics, newMetrics, updateMeasure } from './metrics'
import {
type FullParamsType,
type MeasureContext,
type MeasureLogger,
type Metrics,
type ParamsType,
type OperationLog,
type OperationLogEntry,
type WithOptions
} from './types'
const errorPrinter = ({ message, stack, ...rest }: Error): object => ({
message,
stack,
...rest
})
function replacer (value: any): any {
return value instanceof Error ? errorPrinter(value) : value
}
export const consoleLogger = (logParams: Record<string, any>): MeasureLogger => ({
info: (msg, args) => {
console.info(
msg,
...Object.entries({ ...(args ?? {}), ...(logParams ?? {}) }).map(
(it) => `${it[0]}=${JSON.stringify(replacer(it[1]))}`
)
)
},
error: (msg, args) => {
console.error(
msg,
...Object.entries({ ...(args ?? {}), ...(logParams ?? {}) }).map(
(it) => `${it[0]}=${JSON.stringify(replacer(it[1]))}`
)
)
},
warn: (msg, args) => {
console.warn(msg, ...Object.entries(args ?? {}).map((it) => `${it[0]}=${JSON.stringify(replacer(it[1]))}`))
},
close: async () => {},
logOperation: (operation, time, params) => {}
})
export const noParamsLogger = consoleLogger({})
export const nullPromise = Promise.resolve()
/**
* @public
*/
export class MeasureMetricsContext implements MeasureContext {
private readonly name: string
private readonly params: ParamsType
private readonly fullParams: FullParamsType | (() => FullParamsType) = {}
logger: MeasureLogger
metrics: Metrics
id?: string
st = platformNow()
contextData: object = {}
private done (value?: number, override?: boolean): void {
updateMeasure(this.metrics, this.st, this.params, this.fullParams, (spend) => {}, value, override)
}
constructor (
name: string,
params: ParamsType,
fullParams: FullParamsType | (() => FullParamsType) = {},
metrics: Metrics = newMetrics(),
logger?: MeasureLogger,
readonly parent?: MeasureContext,
readonly logParams?: ParamsType
) {
this.name = name
this.params = params
this.fullParams = fullParams
this.metrics = metrics
this.metrics.namedParams = this.metrics.namedParams ?? {}
for (const [k, v] of Object.entries(params)) {
if (this.metrics.namedParams[k] !== v) {
this.metrics.namedParams[k] = v
} else {
this.metrics.namedParams[k] = '*'
}
}
this.logger = logger ?? (this.logParams != null ? consoleLogger(this.logParams ?? {}) : noParamsLogger)
}
measure (name: string, value: number, override?: boolean): void {
const c = new MeasureMetricsContext('#' + name, {}, {}, childMetrics(this.metrics, ['#' + name]), this.logger, this)
c.contextData = this.contextData
c.done(value, override)
}
newChild (
name: string,
params: ParamsType,
opt?: {
fullParams?: FullParamsType
logger?: MeasureLogger
span?: WithOptions['span'] // By default true
}
): MeasureContext {
const result = new MeasureMetricsContext(
name,
params,
opt?.fullParams ?? {},
childMetrics(this.metrics, [name]),
opt?.logger ?? this.logger,
this,
this.logParams
)
result.id = this.id
result.contextData = this.contextData
return result
}
with<T>(
name: string,
params: ParamsType,
op: (ctx: MeasureContext) => T | Promise<T>,
fullParams?: ParamsType | (() => FullParamsType),
opt?: WithOptions
): Promise<T> {
const c = this.newChild(name, params, { fullParams, logger: this.logger })
let needFinally = true
try {
const value = op(c)
if (value instanceof Promise) {
needFinally = false
return value.finally(() => {
c.end()
if (opt?.log === true) {
this.logger.logOperation(name, platformNowDiff((c as MeasureMetricsContext).st), {
...params,
...fullParams
})
}
})
} else {
if (value == null) {
return nullPromise as Promise<T>
}
return Promise.resolve(value)
}
} finally {
if (needFinally) {
c.end()
}
}
}
extractMeta (): Record<string, string | number | boolean> {
return {}
}
withSync<T>(
name: string,
params: ParamsType,
op: (ctx: MeasureContext) => T,
fullParams?: ParamsType | (() => FullParamsType)
): T {
const c = this.newChild(name, params, { fullParams, logger: this.logger })
try {
return op(c)
} finally {
c.end()
}
}
error (message: string, args?: Record<string, any>): void {
this.logger.error(message, { ...this.params, ...args, ...(this.logParams ?? {}) })
}
info (message: string, args?: Record<string, any>): void {
this.logger.info(message, { ...this.params, ...args, ...(this.logParams ?? {}) })
}
warn (message: string, args?: Record<string, any>): void {
this.logger.warn(message, { ...this.params, ...args, ...(this.logParams ?? {}) })
}
end (): void {
this.done()
}
getParams (): ParamsType {
return this.params
}
}
export class NoMetricsContext implements MeasureContext {
logger: MeasureLogger
id?: string
contextData: object = {}
constructor (logger?: MeasureLogger) {
this.logger = logger ?? consoleLogger({})
}
measure (name: string, value: number, override?: boolean): void {}
newChild (
name: string,
params: ParamsType,
fullParams?: FullParamsType | (() => FullParamsType),
logger?: MeasureLogger
): MeasureContext {
const result = new NoMetricsContext(logger ?? this.logger)
result.id = this.id
result.contextData = this.contextData
return result
}
with<T>(
name: string,
params: ParamsType,
op: (ctx: MeasureContext) => T | Promise<T>,
fullParams?: ParamsType | (() => FullParamsType)
): Promise<T> {
const r = op(this.newChild(name, params, fullParams, this.logger))
return r instanceof Promise ? r : Promise.resolve(r)
}
extractMeta (): Record<string, string | number | boolean> {
return {}
}
withSync<T>(
name: string,
params: ParamsType,
op: (ctx: MeasureContext) => T,
fullParams?: ParamsType | (() => FullParamsType)
): T {
const c = this.newChild(name, params, fullParams, this.logger)
return op(c)
}
withLog<T>(
name: string,
params: ParamsType,
op: (ctx: MeasureContext) => T | Promise<T>,
fullParams?: ParamsType
): Promise<T> {
const r = op(this.newChild(name, params, fullParams, this.logger))
return r instanceof Promise ? r : Promise.resolve(r)
}
error (message: string, args?: Record<string, any>): void {
this.logger.error(message, { ...args })
}
info (message: string, args?: Record<string, any>): void {
this.logger.info(message, { ...args })
}
warn (message: string, args?: Record<string, any>): void {
this.logger.warn(message, { ...args })
}
end (): void {}
getParams (): ParamsType {
return {}
}
}
/**
* Allow to use decorator for context enabled functions
*/
export function withContext (name: string, params: ParamsType = {}): any {
return (target: any, propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor => {
const originalMethod = descriptor.value
descriptor.value = function (...args: any[]): Promise<any> {
const ctx = args[0] as MeasureContext
return ctx.with(name, params, (ctx) => originalMethod.apply(this, [ctx, ...args.slice(1)]) as Promise<any>)
}
return descriptor
}
}
let operationProfiling = false
export function setOperationLogProfiling (value: boolean): void {
operationProfiling = value
}
let globalId: number = 0
export function registerOperationLog (ctx: MeasureContext): { opLogMetrics?: Metrics, op?: OperationLog } {
if (!operationProfiling) {
return {}
}
const op: OperationLog = { start: platformNow(), ops: [], end: -1 }
let opLogMetrics: Metrics | undefined
if (ctx.id === undefined) {
ctx.id = 'op_' + (++globalId).toString(16)
}
if (ctx.metrics !== undefined) {
if (ctx.metrics.opLog === undefined) {
ctx.metrics.opLog = {}
}
ctx.metrics.opLog[ctx.id] = op
opLogMetrics = ctx.metrics
}
return { opLogMetrics, op }
}
export function updateOperationLog (opLogMetrics: Metrics | undefined, op: OperationLog | undefined): void {
if (!operationProfiling) {
return
}
if (op !== undefined) {
op.end = platformNow()
}
// We should keep only longest one entry
if (opLogMetrics?.opLog !== undefined) {
const entries = Object.entries(opLogMetrics.opLog)
const incomplete = entries.filter((it) => it[1].end === -1)
const complete = entries.filter((it) => it[1].end !== -1)
complete.sort((a, b) => a[1].start - b[1].start)
if (complete.length > 30) {
complete.splice(0, complete.length - 30)
}
opLogMetrics.opLog = Object.fromEntries(incomplete.concat(complete))
}
}
export function addOperation<T> (
ctx: MeasureContext,
name: string,
params: ParamsType,
op: (ctx: MeasureContext) => Promise<T>,
fullParams?: FullParamsType
): Promise<T> {
if (!operationProfiling) {
return op(ctx)
}
let opEntry: OperationLogEntry | undefined
let p: MeasureContext | undefined = ctx
let opLogMetrics: Metrics | undefined
let id: string | undefined
while (p !== undefined) {
if (p.metrics?.opLog !== undefined) {
opLogMetrics = p.metrics
}
if (id === undefined && p.id !== undefined) {
id = p.id
}
p = p.parent
}
const opLog = id !== undefined ? opLogMetrics?.opLog?.[id] : undefined
if (opLog !== undefined) {
opEntry = {
op: name,
start: performance.now(),
params: {},
end: -1
}
}
const result = op(ctx)
if (opEntry !== undefined && opLog !== undefined) {
void result.finally(() => {
if (opEntry !== undefined && opLog !== undefined) {
opEntry.end = performance.now()
opEntry.params = { ...params, ...(typeof fullParams === 'function' ? fullParams() : fullParams) }
opLog.ops.push(opEntry)
}
})
}
return result
}
-13
View File
@@ -1,13 +0,0 @@
export * from './context'
export * from './metrics'
export type * from './types'
/**
* Return a current performance timestamp
*/
export const platformNow: () => number = () => performance.now()
/**
* Return a diff with previous performance snapshot with 2 digits after . max.
*/
export const platformNowDiff = (old: number): number => Math.round((performance.now() - old) * 100) / 100
-343
View File
@@ -1,343 +0,0 @@
// Basic performance metrics suite.
import { platformNow, type MetricsData } from '.'
import { type FullParamsType, type Metrics, type ParamsType } from './types'
/**
* @public
*/
export const globals: Metrics = newMetrics()
/**
* @public
* @returns
*/
export function newMetrics (): Metrics {
return {
operations: 0,
value: 0,
measurements: {},
params: {},
namedParams: {}
}
}
function getUpdatedTopResult (
current: Metrics['topResult'],
time: number,
params: FullParamsType
): Metrics['topResult'] {
if (time === 0) {
return current
}
const result: Metrics['topResult'] = current ?? []
const newValue = {
value: time,
params
}
if (result.length > 6) {
if (result[0].value < newValue.value) {
result[0] = newValue
return result
}
if (result[result.length - 1].value > newValue.value) {
result[result.length - 1] = newValue
return result
}
// Shift the middle
return [result[0], newValue, ...result.slice(1, 3), result[5]]
} else {
result.push(newValue)
return result
}
}
/**
* Measure with tree expansion. Operation counter will be added only to leaf's.
* @public
*/
export function measure (
metrics: Metrics,
params: ParamsType,
fullParams: FullParamsType | (() => FullParamsType) = {},
endOp?: (spend: number) => void
): () => void {
const st = platformNow()
return () => {
updateMeasure(metrics, st, params, fullParams, endOp)
}
}
export function updateMeasure (
metrics: Metrics,
st: number,
params: ParamsType,
fullParams: FullParamsType | (() => FullParamsType),
endOp?: (spend: number) => void,
value?: number,
override?: boolean
): void {
const ed = platformNow()
const fParams = typeof fullParams === 'function' ? fullParams() : fullParams
// Update params if required
const pparams = Object.entries(params)
if (pparams.length > 0) {
const [k, v] = pparams[0]
let params = metrics.params[k]
if (params === undefined) {
params = {}
metrics.params[k] = params
}
const vKey = `${v?.toString() ?? ''}`
let param = params[vKey]
if (param === undefined) {
param = {
operations: 0,
value: 0
}
params[vKey] = param
}
if (override === true) {
if (value === 0) {
// We need to delete value, to preserve sending zero values.
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete params[vKey]
} else {
param.operations = value ?? ed - st
}
} else {
param.value += value ?? ed - st
param.operations++
}
// Do not update top results for params.
if (pparams.length > 1) {
// We need to update all other params as counters.
if (param.topResult === undefined) {
param.topResult = []
}
for (const [, v] of pparams.slice(1)) {
const r = (param.topResult ?? []).find((it) => it.params[`${v}`] === true)
if (r !== undefined) {
r.value += 1 // Counter of operations
r.time = (r.time ?? 0) + (value ?? ed - st)
} else {
param.topResult.push({ params: { [`${v}`]: true }, value: 1, time: value ?? ed - st })
}
}
param.topResult.sort((a, b) => b.value - a.value)
}
}
// Update leaf data
if (override === true) {
metrics.operations = value ?? ed - st
} else {
metrics.value += value ?? ed - st
metrics.operations++
}
metrics.topResult = getUpdatedTopResult(metrics.topResult, ed - st, fParams)
endOp?.(ed - st)
}
/**
* @public
*/
export function childMetrics (root: Metrics, path: string[]): Metrics {
const segments = path
let oop = root
for (const p of segments) {
const v = oop.measurements[p] ?? { operations: 0, value: 0, measurements: {}, params: {} }
oop.measurements[p] = v
oop = v
}
return oop
}
/**
* @public
*/
export function metricsAggregate (m: Metrics, limit: number = -1, roundMath: boolean = false): Metrics {
let ms = aggregateMetrics(m.measurements, limit)
// Use child overage, if there is no top level value specified.
const me = Object.entries(ms)
const sumVal: number =
(me.length === 0 ? m.value : 0) +
me
.filter((it) => !it[0].startsWith('#'))
.map((it) => it[1])
.reduce((p, v) => {
return p + v.value
}, 0)
if (limit !== -1) {
// We need to keep only top limit items in ms
if (Object.keys(ms).length > 0) {
const newMs: typeof ms = {}
let added = 0
for (const [k, v] of Object.entries(ms)) {
newMs[k] = v
added++
if (added >= limit) {
break
}
}
ms = newMs
}
}
return {
operations: m.operations,
measurements: ms,
params: m.params,
value: sumVal,
topResult: m.topResult,
namedParams: m.namedParams,
opLog: m.opLog
}
}
function aggregateMetrics (m: Record<string, Metrics>, limit: number = -1): Record<string, Metrics> {
const result: Record<string, Metrics> = {}
for (const [k, v] of Object.entries(m).sort((a, b) => b[1].value - a[1].value)) {
result[k] = metricsAggregate(v, limit)
}
return result
}
function toLen (val: string, sep: string, len: number): string {
while (val.length < len) {
val += sep
}
return val
}
function printMetricsChildren (params: Record<string, Metrics>, offset: number, length: number): string {
let r = ''
if (Object.keys(params).length > 0) {
r += '\n' + toLen('', ' ', offset)
r += Object.entries(params)
.filter((it) => it[1].value > 0.1)
.map(([k, vv]) => toString(k, vv, offset, length))
.join('\n' + toLen('', ' ', offset))
}
return r
}
function printMetricsParams (
params: Record<string, Record<string, MetricsData>>,
offset: number,
length: number
): string {
let r = ''
const joinP = (key: string, data: Record<string, MetricsData>): string[] => {
return Object.entries(data)
.filter((it) => it[1].value >= 0.1)
.map(([k, vv]) =>
`${toLen('', ' ', offset)}${toLen(key + '=' + k, '-', length - offset)}: avg ${
Math.round((vv.value / (vv.operations > 0 ? vv.operations : 1)) * 100) / 100
} total: ${Math.round(vv.value * 100) / 100} ops: ${vv.operations}`.trim()
)
}
const joinParams = Object.entries(params).reduce<string[]>((p, c) => [...p, ...joinP(c[0], c[1])], [])
if (Object.keys(joinParams).length > 0) {
r += '\n' + toLen('', ' ', offset)
r += joinParams.join('\n' + toLen('', ' ', offset))
}
return r
}
function toString (name: string, m: Metrics, offset: number, length: number): string {
let r = `${toLen('', ' ', offset)}${toLen(name, '-', length - offset)}: avg ${
Math.round((m.value / (m.operations > 0 ? m.operations : 1)) * 100) / 100
} total: ${Math.round(m.value * 100) / 100} ops: ${m.operations}`.trim()
r += printMetricsParams(m.params, offset + 4, length)
r += printMetricsChildren(m.measurements, offset + 4, length)
return r
}
function toJson (m: Metrics): any {
const obj: any = {
$total: m.value,
$ops: m.operations
}
if (m.operations > 1) {
obj.avg = Math.round((m.value / (m.operations > 0 ? m.operations : 1)) * 100) / 100
}
if (Object.keys(m.params).length > 0) {
obj.params = m.params
}
for (const [k, v] of Object.entries(m.measurements ?? {})) {
obj[
`${k} ${v.value} ${v.operations} ${
v.operations > 1 ? Math.round((v.value / (v.operations > 0 ? m.operations : 1)) * 100) / 100 : ''
}`
] = toJson(v)
}
return obj
}
/**
* @public
*/
export function metricsToString (metrics: Metrics, name = 'System', length: number): string {
return toString(name, metricsAggregate(metrics, 50, true), 0, length)
}
export function metricsToJson (metrics: Metrics): any {
return toJson(metricsAggregate(metrics))
}
function printMetricsParamsRows (
params: Record<string, Record<string, MetricsData>>,
offset: number
): (string | number)[][] {
const r: (string | number)[][] = []
function joinP (key: string, data: Record<string, MetricsData>): (string | number)[][] {
return Object.entries(data).map(([k, vv]) => [
offset,
`${key}=${k}`,
Math.round((vv.value / (vv.operations > 0 ? vv.operations : 1)) * 100) / 100,
Math.round(vv.value * 100) / 100,
vv.operations
])
}
for (const [k, v] of Object.entries(params)) {
r.push(...joinP(k, v))
}
return r
}
function printMetricsChildrenRows (params: Record<string, Metrics>, offset: number): (string | number)[][] {
const r: (string | number)[][] = []
if (Object.keys(params).length > 0) {
Object.entries(params).forEach(([k, vv]) => r.push(...toStringRows(k, vv, offset)))
}
return r
}
function toStringRows (name: string, m: Metrics, offset: number): (number | string)[][] {
const r: (number | string)[][] = [
[
offset,
name,
Math.round((m.value / (m.operations > 0 ? m.operations : 1)) * 100) / 100,
Math.round(m.value * 100) / 100,
m.operations
]
]
r.push(...printMetricsParamsRows(m.params, offset + 1))
r.push(...printMetricsChildrenRows(m.measurements, offset + 1))
return r
}
/**
* @public
*/
export function metricsToRows (metrics: Metrics, name = 'System'): (number | string)[][] {
return toStringRows(name, metricsAggregate(metrics, 50, true), 0)
}
-132
View File
@@ -1,132 +0,0 @@
/**
* @public
*/
export type ParamType = string | number | boolean | undefined
/**
* @public
*/
export type ParamsType = Record<string, ParamType>
/**
* @public
*/
export type FullParamsType = Record<string, any>
/**
* @public
*/
export interface MetricsData {
operations: number
value: number
topResult?: {
value: number
time?: number
params: FullParamsType
}[]
}
export interface OperationLogEntry {
op: string
params: ParamsType
start: number
end: number
}
export interface OperationLog {
ops: OperationLogEntry[]
start: number
end: number
}
/**
* @public
*/
export interface Metrics extends MetricsData {
namedParams: ParamsType
params: Record<string, Record<string, MetricsData>>
measurements: Record<string, Metrics>
opLog?: Record<string, OperationLog>
}
/**
* @public
*/
export interface MeasureLogger {
info: (message: string, obj?: Record<string, any>) => void
error: (message: string, obj?: Record<string, any>) => void
warn: (message: string, obj?: Record<string, any>) => void
logOperation: (operation: string, time: number, params: ParamsType) => void
childLogger?: (name: string, params: Record<string, any>) => MeasureLogger
close: () => Promise<void>
}
export interface WithOptions {
span?: true | false | 'disable' | 'skip' | 'inherit' // 'none' means no span will be created, 'disable' means context will be tracing disabled
log?: boolean
inheritParams?: boolean
// Passed context metadata
meta?: Record<string, string | number | boolean>
}
/**
* @public
*/
export interface MeasureContext<Q = any> {
id?: string
// Context data will be copied referenced for all child contexts.
contextData: Q
// Create a child metrics context
newChild: (
name: string,
params: ParamsType,
opt?: {
fullParams?: FullParamsType
logger?: MeasureLogger
span?: WithOptions['span'] // By default true
meta?: Record<string, string | number | boolean>
}
) => MeasureContext
metrics?: Metrics
with: <T>(
name: string,
params: ParamsType,
op: (ctx: MeasureContext<Q>) => T | Promise<T>,
fullParams?: FullParamsType | (() => FullParamsType),
opt?: WithOptions
) => Promise<T>
withSync: <T>(
name: string,
params: ParamsType,
op: (ctx: MeasureContext<Q>) => T,
fullParams?: FullParamsType | (() => FullParamsType),
opt?: WithOptions
) => T
extractMeta: () => Record<string, string | number | boolean>
logger: MeasureLogger
parent?: MeasureContext
getParams: () => ParamsType
measure: (name: string, value: number, override?: boolean) => void
// Capture error
error: (message: string, obj?: Record<string, any>) => void
info: (message: string, obj?: Record<string, any>) => void
warn: (message: string, obj?: Record<string, any>) => void
// Mark current context as complete
// If no value is passed, time difference will be used.
end: (value?: number) => void
}
-12
View File
@@ -1,12 +0,0 @@
{
"extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"declarationDir": "./types",
"tsBuildInfoFile": ".build/build.tsbuildinfo"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "lib", "dist", "types", "bundle"]
}
-7
View File
@@ -1,7 +0,0 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/node/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
-8
View File
@@ -1,8 +0,0 @@
*
!/lib/**
!/types/**
!/src/**
!CHANGELOG.md
/lib/**/__test__/
/types/**/__test__/
/src/**/__test__/
-51
View File
@@ -1,51 +0,0 @@
{
"name": "@hcengineering/postgres-base",
"entries": [
{
"version": "0.7.12",
"tag": "@hcengineering/postgres-base_v0.7.12",
"date": "Sat, 11 Oct 2025 19:18:56 GMT",
"comments": {
"patch": [
{
"comment": "rollback eslint"
}
],
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.14` to `0.7.15`"
}
]
}
},
{
"version": "0.7.11",
"tag": "@hcengineering/postgres-base_v0.7.11",
"date": "Sat, 11 Oct 2025 17:58:53 GMT",
"comments": {
"patch": [
{
"comment": "Fix eslint deps"
}
],
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.12` to `0.7.13`"
}
]
}
},
{
"version": "0.7.10",
"tag": "@hcengineering/postgres-base_v0.7.10",
"date": "Fri, 10 Oct 2025 12:32:59 GMT",
"comments": {
"dependency": [
{
"comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.10` to `0.7.11`"
}
]
}
}
]
}
-23
View File
@@ -1,23 +0,0 @@
# Change Log - @hcengineering/postgres-base
This log was last generated on Sat, 11 Oct 2025 19:18:56 GMT and should not be manually modified.
## 0.7.12
Sat, 11 Oct 2025 19:18:56 GMT
### Patches
- rollback eslint
## 0.7.11
Sat, 11 Oct 2025 17:58:53 GMT
### Patches
- Fix eslint deps
## 0.7.10
Fri, 10 Oct 2025 12:32:59 GMT
_Initial release_
-5
View File
@@ -1,5 +0,0 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig",
"rigProfile": "node"
}
-7
View File
@@ -1,7 +0,0 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ["./src"],
coverageReporters: ["text-summary", "html"]
}
-59
View File
@@ -1,59 +0,0 @@
{
"name": "@hcengineering/postgres-base",
"version": "0.7.12",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
"author": "Copyright © Hardcore Engineering Inc.",
"template": "@hcengineering/node-package",
"license": "EPL-2.0",
"files": [
"lib/**/*",
"!lib/**/__test__/**",
"types/**/*",
"!types/**/__test__/**",
"src/**/*",
"!src/**/__test__/**",
"README.md",
"CHANGELOG.md"
],
"scripts": {
"build": "compile",
"build:watch": "compile",
"test": "jest --passWithNoTests --silent --forceExit",
"format": "format src",
"_phase:build": "compile transpile src",
"_phase:test": "jest --passWithNoTests --silent --forceExit",
"_phase:format": "format src",
"_phase:validate": "compile validate"
},
"devDependencies": {
"@hcengineering/platform-rig": "workspace:^0.7.19",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"@types/node": "^22.15.29",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint": "^8.54.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-n": "^15.4.0"
},
"dependencies": {
"postgres": "^3.4.7"
},
"repository": "https://github.com/hcengineering/huly.utils",
"exports": {
".": {
"types": "./types/index.d.ts",
"require": "./lib/index.js",
"import": "./lib/index.js"
}
},
"publishConfig": {
"access": "public"
}
}
-439
View File
@@ -1,439 +0,0 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import postgres, { type Options, type ParameterOrJSON } from 'postgres'
const clientRefs = new Map<number, ClientRef>()
let clId = 0
export type DBResult = any[] & { count: number }
export interface DBClient {
execute: (query: string, parameters?: ParameterOrJSON<any>[] | undefined) => Promise<DBResult>
release: () => void
reserve: () => Promise<DBClient>
raw: () => postgres.Sql
}
export function createDBClient (client: postgres.Sql, release: () => void = () => {}): DBClient {
return {
execute: (query, parameters) =>
client.unsafe(query, doFetchTypes ? parameters : convertArrayParams(parameters), getPrepare()),
release,
reserve: async () => {
const reserved = await client.reserve()
return createDBClient(reserved, () => {
reserved.release()
})
},
raw: () => client
}
}
export function convertArrayParams (params?: unknown[]): any[] | undefined {
if (params === undefined) return undefined
return params.map((param) => {
if (!Array.isArray(param)) return param
if (param.length === 0) return '{}'
const sanitized = param.map((item) => {
if (item === null || item === undefined) return 'NULL'
if (typeof item === 'number' || typeof item === 'boolean') {
return String(item)
}
if (typeof item === 'string') {
const escaped = item.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
return `"${escaped}"`
}
const json = JSON.stringify(item)
const escapedJson = json.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
return `"${escapedJson}"`
})
return `{${sanitized.join(',')}}`
})
}
export async function retryTxn (
pool: postgres.Sql,
operation: (client: postgres.TransactionSql) => Promise<any>
): Promise<any> {
await pool.begin(async (client) => {
const result = await operation(client)
return result
})
}
/**
* @public
*/
export async function shutdownPostgres (): Promise<void> {
for (const c of connections.values()) {
c.close(true)
}
connections.clear()
}
export interface PostgresClientReference {
getClient: () => Promise<postgres.Sql>
mgr: ConnectionMgr
close: () => void
url: () => string
}
class PostgresClientReferenceImpl {
count: number
client: postgres.Sql
mgr: ConnectionMgr
constructor (
readonly connectionString: string,
client: postgres.Sql,
readonly onclose: () => void
) {
this.count = 0
this.client = client
this.mgr = new ConnectionMgr(createDBClient(this.client))
}
url (): string {
return this.connectionString
}
getClient (): postgres.Sql {
return this.client
}
close (force: boolean = false): void {
this.count--
if (this.count === 0 || force) {
if (force) {
this.count = 0
}
void (async () => {
this.mgr.close()
this.onclose()
const cl = this.client
await cl.end({ timeout: 1 })
})()
}
}
addRef (): void {
this.count++
}
}
export class ClientRef implements PostgresClientReference {
id = ++clId
constructor (
readonly client: PostgresClientReferenceImpl,
readonly mgr: ConnectionMgr
) {
clientRefs.set(this.id, this)
}
url (): string {
return this.client.url()
}
closed = false
async getClient (): Promise<postgres.Sql> {
if (!this.closed) {
return this.client.getClient()
} else {
throw Error('DB client is already closed')
}
}
close (): void {
// Do not allow double close of mongo connection client
if (!this.closed) {
clientRefs.delete(this.id)
this.closed = true
this.client.close()
}
}
}
export let dbExtraOptions: Partial<Options<any>> = {}
export function setDBExtraOptions (options: Partial<Options<any>>): void {
dbExtraOptions = options
}
export function getPrepare (): { prepare: boolean } {
return { prepare: dbExtraOptions.prepare ?? false }
}
export const doFetchTypes = true
const connections = new Map<string, PostgresClientReferenceImpl>()
/**
* Initialize a connection to DB
* @public
*/
export function getDBClient (
connectionString: string,
database?: string,
serviceName: string = 'transactor'
): PostgresClientReference {
const extraOptions = JSON.parse(process.env.POSTGRES_OPTIONS ?? '{}')
const key = `${connectionString}${extraOptions}`
let existing = connections.get(key)
if (existing === undefined) {
const sql = postgres(connectionString, {
connection: {
application_name: serviceName
},
database,
max: 10,
min: 2,
connect_timeout: 30,
idle_timeout: 0,
transform: {
undefined: null
},
debug: false,
notice: false,
onnotice (notice) {},
onparameter (key, value) {},
...dbExtraOptions,
...extraOptions,
fetch_types: doFetchTypes
})
existing = new PostgresClientReferenceImpl(connectionString, sql, () => {
connections.delete(key)
})
connections.set(key, existing)
}
// Add reference and return once closable
existing.addRef()
return new ClientRef(existing, existing.mgr)
}
class ConnectionInfo {
// It should preserve at least one available connection in pool, other connection should be closed
available: DBClient[] = []
released: boolean = false
constructor (
readonly connectionId: string,
protected readonly client: DBClient,
readonly managed: boolean,
readonly mgrId: string
) {}
async withReserve (action: (reservedClient: DBClient) => Promise<any>, forced: boolean = false): Promise<any> {
let reserved: DBClient | undefined
// Check if we have at least one available connection and reserve one more if required.
if (this.available.length === 0) {
if (this.managed || forced) {
reserved = await this.client.reserve()
}
} else {
reserved = this.available.shift() as DBClient
}
try {
// Use reserved or pool
return await action(reserved ?? this.client)
} catch (err: any) {
console.error(err)
throw err
} finally {
if (this.released) {
try {
reserved?.release()
} catch (err: any) {
console.error('failed to release', err)
}
} else if (reserved !== undefined) {
if (this.available.length > 0) {
reserved?.release()
} else {
this.available.push(reserved)
}
}
}
}
release (): void {
for (const c of [...this.available]) {
c.release()
}
this.available = []
}
}
export class ConnectionMgr {
private readonly connections = new Map<string, ConnectionInfo>()
constructor (protected readonly client: DBClient) {}
async write (id: string | undefined, mgrId: string, fn: (client: DBClient) => Promise<any>): Promise<void> {
const backoffInterval = 25 // millis
const maxTries = 5
let tries = 0
const realId = id ?? `${++clId}`
const connection = this.getConnection(realId, mgrId, false)
try {
while (true) {
const retry: boolean | Error = await connection.withReserve(async (client) => {
tries++
try {
await client.execute('BEGIN;')
await fn(client)
await client.execute('COMMIT;')
return true
} catch (err: any) {
await client.execute('ROLLBACK;')
console.error({ message: 'failed to process tx', error: err.message, cause: err })
if (!this.isRetryableError(err) || tries === maxTries) {
return err
} else {
console.log('Transaction failed. Retrying.')
console.log(err.message)
return false
}
}
}, true)
if (retry === true) {
break
}
if (retry instanceof Error) {
// Pass it to exit
throw retry
}
// Retry for a timeout
await new Promise((resolve) => setTimeout(resolve, backoffInterval))
}
} finally {
if (!connection.managed) {
// We need to relase in case it temporaty connection was used
connection.release()
}
}
}
async retry (id: string | undefined, mgrId: string, fn: (client: DBClient) => Promise<any>): Promise<any> {
const backoffInterval = 25 // millis
const maxTries = 5
let tries = 0
const realId = id ?? `${++clId}`
// Will reuse reserved if had and use new one if not
const connection = this.getConnection(realId, mgrId, false)
try {
while (true) {
const retry: false | { result: any } | Error = await connection.withReserve(async (client) => {
tries++
try {
return { result: await fn(client) }
} catch (err: any) {
console.error({ message: 'failed to process sql', error: err.message, cause: err })
if (!this.isRetryableError(err) || tries === maxTries) {
return err
} else {
console.log('Read Transaction failed. Retrying.')
console.log(err.message)
return false
}
}
})
if (retry instanceof Error) {
// Pass it to exit
throw retry
}
if (retry === false) {
// Retry for a timeout
await new Promise((resolve) => setTimeout(resolve, backoffInterval))
continue
}
return retry.result
}
} finally {
if (!connection.managed) {
// We need to relase in case it temporaty connection was used
connection.release()
}
}
}
release (id: string): void {
const conn = this.connections.get(id)
if (conn !== undefined) {
conn.released = true
this.connections.delete(id) // We need to delete first
conn.release()
}
}
close (mgrId?: string): void {
const cnts = this.connections
for (const [k, conn] of Array.from(cnts.entries())) {
if (mgrId !== undefined && conn.mgrId !== mgrId) {
continue
}
cnts.delete(k)
try {
conn.release()
} catch (err: any) {
console.error('failed to release connection')
}
}
}
getConnection (id: string, mgrId: string, managed: boolean = true): ConnectionInfo {
let conn = this.connections.get(id)
if (conn === undefined) {
conn = new ConnectionInfo(id, this.client, managed, mgrId)
}
if (managed) {
this.connections.set(id, conn)
}
return conn
}
private isRetryableError (err: any): boolean {
const msg: string = err?.message ?? ''
return (
err.code === '40001' || // Retry transaction
err.code === '55P03' || // Lock not available
err.code === 'CONNECTION_CLOSED' || // This error is thrown if the connection was closed without an error.
err.code === 'CONNECTION_DESTROYED' || // This error is thrown for any queries that were pending when the timeout to sql.end({ timeout: X }) was reached. If the DB client is being closed completely retry will result in CONNECTION_ENDED which is not retried so should be fine.
msg.includes('RETRY_SERIALIZABLE')
)
}
}
-12
View File
@@ -1,12 +0,0 @@
{
"extends": "./node_modules/@hcengineering/platform-rig/profiles/node/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"declarationDir": "./types",
"tsBuildInfoFile": ".build/build.tsbuildinfo"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "lib", "dist", "types", "bundle"]
}
-15
View File
@@ -341,21 +341,6 @@
"reviewCategory": "tools",
"shouldPublish": true
},
{
"packageName": "@hcengineering/measurements",
"projectFolder": "packages/measurements",
"shouldPublish": true
},
{
"packageName": "@hcengineering/measurements-otlp",
"projectFolder": "packages/measurements-otlp",
"shouldPublish": true
},
{
"packageName": "@hcengineering/postgres-base",
"projectFolder": "packages/postgres-base",
"shouldPublish": true
},
{
"packageName": "@hcengineering/ui-test",
"projectFolder": "packages/ui-test",