@cosmicdrift/kumiko-framework 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -0
- package/package.json +91 -0
- package/src/__tests__/anonymous-access.integration.ts +325 -0
- package/src/__tests__/error-contract.integration.ts +435 -0
- package/src/__tests__/field-access.integration.ts +269 -0
- package/src/__tests__/full-stack.integration.ts +914 -0
- package/src/__tests__/ownership.integration.ts +449 -0
- package/src/__tests__/reference-data.integration.ts +198 -0
- package/src/__tests__/transition-guard.integration.ts +340 -0
- package/src/api/__tests__/api.test.ts +337 -0
- package/src/api/__tests__/auth-middleware-transport.test.ts +80 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +179 -0
- package/src/api/__tests__/batch.integration.ts +404 -0
- package/src/api/__tests__/body-limit.test.ts +88 -0
- package/src/api/__tests__/csrf-middleware.test.ts +97 -0
- package/src/api/__tests__/dispatcher-live.integration.ts +216 -0
- package/src/api/__tests__/metrics-endpoint.test.ts +126 -0
- package/src/api/__tests__/nested-write.integration.ts +213 -0
- package/src/api/__tests__/readiness.test.ts +76 -0
- package/src/api/__tests__/request-id-middleware.test.ts +72 -0
- package/src/api/__tests__/sse-broker.test.ts +58 -0
- package/src/api/__tests__/sse-route.test.ts +112 -0
- package/src/api/anonymous-cookie.ts +60 -0
- package/src/api/api-constants.ts +64 -0
- package/src/api/auth-middleware.ts +418 -0
- package/src/api/auth-routes.ts +982 -0
- package/src/api/csrf-middleware.ts +77 -0
- package/src/api/index.ts +31 -0
- package/src/api/jwt.ts +66 -0
- package/src/api/observability-middleware.ts +89 -0
- package/src/api/readiness.ts +132 -0
- package/src/api/request-context.ts +49 -0
- package/src/api/request-id-middleware.ts +50 -0
- package/src/api/route-registrars.ts +195 -0
- package/src/api/routes.ts +135 -0
- package/src/api/server.ts +640 -0
- package/src/api/sse-broker.ts +71 -0
- package/src/api/sse-route.ts +62 -0
- package/src/api/tokens.ts +16 -0
- package/src/db/__tests__/apply-entity-event-tenant.integration.ts +159 -0
- package/src/db/__tests__/compound-types.test.ts +114 -0
- package/src/db/__tests__/connection-options.test.ts +68 -0
- package/src/db/__tests__/cursor.test.ts +41 -0
- package/src/db/__tests__/db-helpers.test.ts +369 -0
- package/src/db/__tests__/dialect-instant.test.ts +50 -0
- package/src/db/__tests__/drizzle-helpers.integration.ts +186 -0
- package/src/db/__tests__/drizzle-table-types.test.ts +162 -0
- package/src/db/__tests__/encryption.test.ts +39 -0
- package/src/db/__tests__/event-store-executor-list.integration.ts +313 -0
- package/src/db/__tests__/event-store-executor.integration.ts +235 -0
- package/src/db/__tests__/implicit-projection-equivalence.integration.ts +304 -0
- package/src/db/__tests__/located-timestamp.test.ts +184 -0
- package/src/db/__tests__/money.test.ts +199 -0
- package/src/db/__tests__/multi-row-insert.integration.ts +76 -0
- package/src/db/__tests__/parse-auto-verb.test.ts +70 -0
- package/src/db/__tests__/required-not-null-migration-safety.integration.ts +105 -0
- package/src/db/__tests__/row-helpers.test.ts +59 -0
- package/src/db/__tests__/schema-migration.integration.ts +273 -0
- package/src/db/__tests__/table-builder-indexes.test.ts +153 -0
- package/src/db/__tests__/table-builder-required.test.ts +216 -0
- package/src/db/__tests__/tenant-db.integration.ts +606 -0
- package/src/db/__tests__/unique-violation-mapping.integration.ts +166 -0
- package/src/db/apply-entity-event.ts +188 -0
- package/src/db/assert-exists-in.ts +59 -0
- package/src/db/compound-types.ts +47 -0
- package/src/db/connection.ts +104 -0
- package/src/db/cursor.ts +83 -0
- package/src/db/dialect.ts +109 -0
- package/src/db/eagerload.ts +174 -0
- package/src/db/encryption.ts +39 -0
- package/src/db/event-store-executor.ts +906 -0
- package/src/db/index.ts +55 -0
- package/src/db/located-timestamp.ts +114 -0
- package/src/db/money.ts +120 -0
- package/src/db/pg-error.ts +46 -0
- package/src/db/reference-data.ts +77 -0
- package/src/db/row-helpers.ts +53 -0
- package/src/db/schema-inspection.ts +25 -0
- package/src/db/table-builder.ts +475 -0
- package/src/db/tenant-db.ts +434 -0
- package/src/engine/__tests__/auth-claims-registrar.test.ts +74 -0
- package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +108 -0
- package/src/engine/__tests__/boot-validator.test.ts +1865 -0
- package/src/engine/__tests__/build-app-schema.test.ts +154 -0
- package/src/engine/__tests__/claim-keys.test.ts +274 -0
- package/src/engine/__tests__/config-helpers.test.ts +236 -0
- package/src/engine/__tests__/effective-features.test.ts +86 -0
- package/src/engine/__tests__/engine.test.ts +1461 -0
- package/src/engine/__tests__/entity-handlers.test.ts +274 -0
- package/src/engine/__tests__/event-helpers.test.ts +68 -0
- package/src/engine/__tests__/extends-registrar.test.ts +159 -0
- package/src/engine/__tests__/factories-long-text.test.ts +84 -0
- package/src/engine/__tests__/factories-time.test.ts +158 -0
- package/src/engine/__tests__/field-predicates.test.ts +48 -0
- package/src/engine/__tests__/hook-phases.test.ts +132 -0
- package/src/engine/__tests__/identifiers.test.ts +35 -0
- package/src/engine/__tests__/lifecycle-hooks.test.ts +237 -0
- package/src/engine/__tests__/nav.test.ts +267 -0
- package/src/engine/__tests__/ownership.test.ts +421 -0
- package/src/engine/__tests__/parse-ref-target.test.ts +43 -0
- package/src/engine/__tests__/projection-helpers.test.ts +62 -0
- package/src/engine/__tests__/projection.test.ts +191 -0
- package/src/engine/__tests__/qualified-name.test.ts +264 -0
- package/src/engine/__tests__/resolve-config-or-param.test.ts +315 -0
- package/src/engine/__tests__/run-in.test.ts +38 -0
- package/src/engine/__tests__/schema-builder.test.ts +380 -0
- package/src/engine/__tests__/screen.test.ts +408 -0
- package/src/engine/__tests__/state-machine.test.ts +148 -0
- package/src/engine/__tests__/system-user.test.ts +57 -0
- package/src/engine/__tests__/validation-hooks.test.ts +71 -0
- package/src/engine/access.ts +23 -0
- package/src/engine/boot-validator.ts +1528 -0
- package/src/engine/build-app-schema.ts +125 -0
- package/src/engine/config-helpers.ts +115 -0
- package/src/engine/constants.ts +85 -0
- package/src/engine/create-app.ts +98 -0
- package/src/engine/define-feature.ts +702 -0
- package/src/engine/define-handler.ts +78 -0
- package/src/engine/define-roles.ts +19 -0
- package/src/engine/effective-features.ts +87 -0
- package/src/engine/entity-handlers.ts +364 -0
- package/src/engine/event-helpers.ts +73 -0
- package/src/engine/factories.ts +328 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +416 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +197 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +128 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +888 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +360 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +469 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +287 -0
- package/src/engine/feature-ast/extractors.ts +2562 -0
- package/src/engine/feature-ast/index.ts +105 -0
- package/src/engine/feature-ast/parse.ts +369 -0
- package/src/engine/feature-ast/patch.ts +525 -0
- package/src/engine/feature-ast/patcher.ts +518 -0
- package/src/engine/feature-ast/patterns.ts +434 -0
- package/src/engine/feature-ast/render.ts +602 -0
- package/src/engine/feature-ast/source-location.ts +45 -0
- package/src/engine/field-access.ts +120 -0
- package/src/engine/index.ts +254 -0
- package/src/engine/ownership.ts +337 -0
- package/src/engine/parse-ref-target.ts +22 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +351 -0
- package/src/engine/pattern-library/index.ts +24 -0
- package/src/engine/pattern-library/library.ts +1117 -0
- package/src/engine/pattern-library/types.ts +255 -0
- package/src/engine/projection-helpers.ts +85 -0
- package/src/engine/qualified-name.ts +122 -0
- package/src/engine/read-claim.ts +31 -0
- package/src/engine/registry.ts +1325 -0
- package/src/engine/resolve-config-or-param.ts +153 -0
- package/src/engine/run-in.ts +29 -0
- package/src/engine/schema-builder.ts +175 -0
- package/src/engine/screen-filter-ops.ts +51 -0
- package/src/engine/state-machine.ts +70 -0
- package/src/engine/system-user.ts +32 -0
- package/src/engine/types/config.ts +306 -0
- package/src/engine/types/event-type-map.ts +37 -0
- package/src/engine/types/feature.ts +574 -0
- package/src/engine/types/fields.ts +422 -0
- package/src/engine/types/handlers.ts +742 -0
- package/src/engine/types/hooks.ts +142 -0
- package/src/engine/types/http-route.ts +54 -0
- package/src/engine/types/identifiers.ts +47 -0
- package/src/engine/types/index.ts +208 -0
- package/src/engine/types/nav.ts +46 -0
- package/src/engine/types/projection.ts +132 -0
- package/src/engine/types/relations.ts +51 -0
- package/src/engine/types/screen.ts +452 -0
- package/src/engine/types/workspace.ts +42 -0
- package/src/engine/validation.ts +33 -0
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.ts +173 -0
- package/src/entrypoint/__tests__/split-deploy.integration.ts +297 -0
- package/src/entrypoint/index.ts +442 -0
- package/src/errors/__tests__/classes.test.ts +371 -0
- package/src/errors/__tests__/write-failures.test.ts +109 -0
- package/src/errors/classes.ts +249 -0
- package/src/errors/i18n/de.yaml +83 -0
- package/src/errors/i18n/en.yaml +80 -0
- package/src/errors/index.ts +41 -0
- package/src/errors/kumiko-error.ts +67 -0
- package/src/errors/reasons.ts +36 -0
- package/src/errors/serialize.ts +136 -0
- package/src/errors/transition-details.ts +30 -0
- package/src/errors/write-error-info.ts +123 -0
- package/src/errors/zod-bridge.ts +49 -0
- package/src/event-store/__tests__/admin-api.integration.ts +361 -0
- package/src/event-store/__tests__/event-store.integration.ts +584 -0
- package/src/event-store/__tests__/get-stream-version-perf.integration.ts +83 -0
- package/src/event-store/__tests__/perf.integration.ts +255 -0
- package/src/event-store/__tests__/snapshot.integration.ts +267 -0
- package/src/event-store/__tests__/upcaster-dead-letter.integration.ts +204 -0
- package/src/event-store/__tests__/upcaster.integration.ts +460 -0
- package/src/event-store/admin-api.ts +257 -0
- package/src/event-store/archive.ts +106 -0
- package/src/event-store/errors.ts +35 -0
- package/src/event-store/event-store.ts +405 -0
- package/src/event-store/events-schema.ts +90 -0
- package/src/event-store/index.ts +50 -0
- package/src/event-store/snapshot.ts +210 -0
- package/src/event-store/upcaster-dead-letter.ts +119 -0
- package/src/event-store/upcaster.ts +147 -0
- package/src/files/__tests__/content-disposition.test.ts +123 -0
- package/src/files/__tests__/file-field-column.integration.ts +103 -0
- package/src/files/__tests__/file-field-pipeline.integration.ts +211 -0
- package/src/files/__tests__/file-handle.test.ts +122 -0
- package/src/files/__tests__/files.integration.ts +830 -0
- package/src/files/__tests__/storage-tracking.integration.ts +153 -0
- package/src/files/content-disposition.ts +55 -0
- package/src/files/file-handle.ts +63 -0
- package/src/files/file-ref-table.ts +22 -0
- package/src/files/file-routes.ts +353 -0
- package/src/files/in-memory-provider.ts +62 -0
- package/src/files/index.ts +29 -0
- package/src/files/local-provider.ts +35 -0
- package/src/files/storage-tracking.ts +60 -0
- package/src/files/types.ts +118 -0
- package/src/i18n/__tests__/i18n.test.ts +72 -0
- package/src/i18n/index.ts +29 -0
- package/src/jobs/__tests__/job-event-trigger.integration.ts +172 -0
- package/src/jobs/__tests__/job-multi-trigger.integration.ts +144 -0
- package/src/jobs/__tests__/jobs.integration.ts +566 -0
- package/src/jobs/index.ts +2 -0
- package/src/jobs/job-runner.ts +574 -0
- package/src/lifecycle/__tests__/create-test-lifecycle.ts +19 -0
- package/src/lifecycle/__tests__/lifecycle-server.integration.ts +108 -0
- package/src/lifecycle/__tests__/lifecycle.test.ts +212 -0
- package/src/lifecycle/__tests__/signal-handlers.test.ts +106 -0
- package/src/lifecycle/index.ts +13 -0
- package/src/lifecycle/lifecycle.ts +160 -0
- package/src/lifecycle/signal-handlers.ts +62 -0
- package/src/logging/__tests__/pino-trace-bridge.test.ts +50 -0
- package/src/logging/index.ts +3 -0
- package/src/logging/pino-logger.ts +64 -0
- package/src/logging/types.ts +7 -0
- package/src/migrations/__tests__/compare-snapshots.test.ts +150 -0
- package/src/migrations/__tests__/detect-drift.integration.ts +320 -0
- package/src/migrations/__tests__/detect-projections-to-rebuild.integration.ts +134 -0
- package/src/migrations/__tests__/rebuild-marker.test.ts +79 -0
- package/src/migrations/index.ts +28 -0
- package/src/migrations/projection-detection.ts +149 -0
- package/src/migrations/rebuild-marker.ts +64 -0
- package/src/migrations/schema-drift.ts +395 -0
- package/src/observability/__tests__/console-provider.test.ts +67 -0
- package/src/observability/__tests__/metric-validator.test.ts +87 -0
- package/src/observability/__tests__/noop-provider.test.ts +82 -0
- package/src/observability/__tests__/observability.integration.ts +559 -0
- package/src/observability/__tests__/prometheus-meter.test.ts +144 -0
- package/src/observability/__tests__/recording-meter.test.ts +101 -0
- package/src/observability/__tests__/recording-tracer.test.ts +110 -0
- package/src/observability/__tests__/sensitive-filter.test.ts +98 -0
- package/src/observability/console-provider.ts +130 -0
- package/src/observability/context.ts +26 -0
- package/src/observability/fallback.ts +34 -0
- package/src/observability/ids.ts +25 -0
- package/src/observability/index.ts +79 -0
- package/src/observability/metric-validator.ts +86 -0
- package/src/observability/metrics-handle.ts +56 -0
- package/src/observability/noop-provider.ts +146 -0
- package/src/observability/prometheus-meter.ts +284 -0
- package/src/observability/recording-meter.ts +156 -0
- package/src/observability/recording-tracer.ts +198 -0
- package/src/observability/redis-wrapper.ts +132 -0
- package/src/observability/sensitive-filter.ts +108 -0
- package/src/observability/standard-metrics.ts +213 -0
- package/src/observability/types/index.ts +29 -0
- package/src/observability/types/metric.ts +56 -0
- package/src/observability/types/provider.ts +32 -0
- package/src/observability/types/span.ts +64 -0
- package/src/pipeline/__tests__/archive-stream.integration.ts +220 -0
- package/src/pipeline/__tests__/auth-claims-resolver.test.ts +279 -0
- package/src/pipeline/__tests__/cascade-handler.integration.ts +419 -0
- package/src/pipeline/__tests__/cascade-handler.test.ts +52 -0
- package/src/pipeline/__tests__/causation-chain.integration.ts +206 -0
- package/src/pipeline/__tests__/ctx-bridge.integration.ts +234 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +379 -0
- package/src/pipeline/__tests__/distributed-lock.integration.ts +67 -0
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +323 -0
- package/src/pipeline/__tests__/event-dedup.integration.ts +153 -0
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +220 -0
- package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +423 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +123 -0
- package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +290 -0
- package/src/pipeline/__tests__/event-dispatcher-strict.test.ts +65 -0
- package/src/pipeline/__tests__/event-dispatcher.integration.ts +287 -0
- package/src/pipeline/__tests__/event-retention.integration.ts +239 -0
- package/src/pipeline/__tests__/fetch-for-writing.integration.ts +281 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +430 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +266 -0
- package/src/pipeline/__tests__/msp-error-mode.integration.ts +149 -0
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +228 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +368 -0
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +341 -0
- package/src/pipeline/__tests__/perf-rebuild.integration.ts +147 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.ts +551 -0
- package/src/pipeline/__tests__/query-projection.integration.ts +201 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.ts +306 -0
- package/src/pipeline/append-event-core.ts +117 -0
- package/src/pipeline/auth-claims-resolver.ts +103 -0
- package/src/pipeline/cascade-handler.ts +113 -0
- package/src/pipeline/dispatcher.ts +1585 -0
- package/src/pipeline/distributed-lock.ts +37 -0
- package/src/pipeline/entity-cache.ts +113 -0
- package/src/pipeline/event-consumer-state.ts +108 -0
- package/src/pipeline/event-dedup.ts +23 -0
- package/src/pipeline/event-dispatcher.ts +1016 -0
- package/src/pipeline/event-retention.ts +154 -0
- package/src/pipeline/idempotency.ts +76 -0
- package/src/pipeline/index.ts +66 -0
- package/src/pipeline/lifecycle-pipeline.ts +409 -0
- package/src/pipeline/msp-rebuild.ts +242 -0
- package/src/pipeline/multi-stream-apply-context.ts +115 -0
- package/src/pipeline/projection-rebuild.ts +334 -0
- package/src/pipeline/projection-state.ts +72 -0
- package/src/pipeline/projections-runner.ts +56 -0
- package/src/pipeline/redis-keys.ts +11 -0
- package/src/pipeline/system-hooks.ts +190 -0
- package/src/random/__tests__/generate.test.ts +149 -0
- package/src/random/generate.ts +141 -0
- package/src/random/index.ts +8 -0
- package/src/random/words.ts +392 -0
- package/src/rate-limit/__tests__/dispatcher-l3.integration.ts +111 -0
- package/src/rate-limit/__tests__/middleware.integration.ts +189 -0
- package/src/rate-limit/__tests__/resolver.integration.ts +189 -0
- package/src/rate-limit/bucket.ts +36 -0
- package/src/rate-limit/index.ts +14 -0
- package/src/rate-limit/middleware.ts +152 -0
- package/src/rate-limit/resolver.ts +267 -0
- package/src/redis/__tests__/redis-options.test.ts +54 -0
- package/src/redis/index.ts +74 -0
- package/src/search/__tests__/meilisearch-adapter.integration.ts +236 -0
- package/src/search/__tests__/search-adapter.test.ts +256 -0
- package/src/search/in-memory-adapter.ts +123 -0
- package/src/search/index.ts +12 -0
- package/src/search/meilisearch-adapter.ts +106 -0
- package/src/search/types.ts +39 -0
- package/src/secrets/__tests__/dek-cache.test.ts +213 -0
- package/src/secrets/__tests__/env-master-key-provider.test.ts +119 -0
- package/src/secrets/__tests__/envelope.test.ts +74 -0
- package/src/secrets/__tests__/leak-guard.test.ts +92 -0
- package/src/secrets/__tests__/rotation.test.ts +149 -0
- package/src/secrets/dek-cache.ts +116 -0
- package/src/secrets/env-master-key-provider.ts +162 -0
- package/src/secrets/envelope.ts +55 -0
- package/src/secrets/index.ts +19 -0
- package/src/secrets/leak-guard.ts +87 -0
- package/src/secrets/rotation.ts +34 -0
- package/src/secrets/types.ts +107 -0
- package/src/stack/db.ts +104 -0
- package/src/stack/event-collector.ts +23 -0
- package/src/stack/index.ts +32 -0
- package/src/stack/redis.ts +44 -0
- package/src/stack/request-helper.ts +168 -0
- package/src/stack/table-helpers.ts +104 -0
- package/src/stack/test-stack.ts +357 -0
- package/src/stack/test-users.ts +37 -0
- package/src/testing/__tests__/e2e-generator.test.ts +230 -0
- package/src/testing/__tests__/ensure-entity-table.integration.ts +54 -0
- package/src/testing/access-assertions.ts +15 -0
- package/src/testing/assertions.ts +35 -0
- package/src/testing/e2e-generator.ts +465 -0
- package/src/testing/expect-error.ts +25 -0
- package/src/testing/handler-context.ts +125 -0
- package/src/testing/http-cookies.ts +52 -0
- package/src/testing/index.ts +41 -0
- package/src/testing/late-bound.ts +39 -0
- package/src/testing/mutable-master-key-provider.ts +31 -0
- package/src/testing/observability-recorder.ts +54 -0
- package/src/testing/shared-entities.ts +49 -0
- package/src/testing/utils.ts +1 -0
- package/src/testing/wait-for.ts +31 -0
- package/src/time/__tests__/polyfill.test.ts +73 -0
- package/src/time/__tests__/tz-context.test.ts +121 -0
- package/src/time/index.ts +21 -0
- package/src/time/polyfill.ts +70 -0
- package/src/time/tz-context.ts +107 -0
- package/src/ui-types/app-schema.ts +57 -0
- package/src/ui-types/index.ts +65 -0
- package/src/utils/__tests__/assert.test.ts +17 -0
- package/src/utils/__tests__/env-parse.test.ts +54 -0
- package/src/utils/assert.ts +18 -0
- package/src/utils/env-parse.ts +16 -0
- package/src/utils/ids.ts +16 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/safe-json.ts +30 -0
- package/src/utils/serialization.ts +7 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { buildMetricName } from "./metric-validator";
|
|
2
|
+
import type { Meter, MetricLabels, MetricsHandle } from "./types";
|
|
3
|
+
|
|
4
|
+
// Feature-bound MetricsHandle: the short name a handler writes
|
|
5
|
+
// (e.g. "created_total") is resolved to the fully qualified name
|
|
6
|
+
// (e.g. "kumiko_orders_created_total") using the feature the current
|
|
7
|
+
// handler belongs to.
|
|
8
|
+
//
|
|
9
|
+
// The Meter enforces that the resolved name is registered — unregistered
|
|
10
|
+
// metrics throw, so typos surface at first call rather than drifting into
|
|
11
|
+
// dashboards. The feature name itself is validated via buildMetricName.
|
|
12
|
+
|
|
13
|
+
export function createMetricsHandle(meter: Meter, featureName: string): MetricsHandle {
|
|
14
|
+
return {
|
|
15
|
+
inc(shortName, labels, value) {
|
|
16
|
+
const name = buildMetricName(featureName, shortName);
|
|
17
|
+
meter.counter(name).inc(value, labels);
|
|
18
|
+
},
|
|
19
|
+
observe(shortName, value, labels) {
|
|
20
|
+
const name = buildMetricName(featureName, shortName);
|
|
21
|
+
meter.histogram(name).observe(value, labels);
|
|
22
|
+
},
|
|
23
|
+
set(shortName, value, labels) {
|
|
24
|
+
const name = buildMetricName(featureName, shortName);
|
|
25
|
+
meter.gauge(name).set(value, labels);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Fallback for contexts where the feature is unknown (e.g. system-hooks,
|
|
31
|
+
// internal pipeline code). Short names are used verbatim — useful for
|
|
32
|
+
// framework-level usage, but rejected by the Meter unless pre-registered.
|
|
33
|
+
export function createUnboundMetricsHandle(meter: Meter): MetricsHandle {
|
|
34
|
+
return {
|
|
35
|
+
inc(name, labels, value) {
|
|
36
|
+
meter.counter(name).inc(value, labels);
|
|
37
|
+
},
|
|
38
|
+
observe(name, value, labels) {
|
|
39
|
+
meter.histogram(name).observe(value, labels);
|
|
40
|
+
},
|
|
41
|
+
set(name, value, labels) {
|
|
42
|
+
meter.gauge(name).set(value, labels);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Noop fallback used when no provider is configured and for safety in
|
|
48
|
+
// contexts where we can't determine the feature. Every call is a no-op —
|
|
49
|
+
// tests and non-observability-aware features never crash.
|
|
50
|
+
export function createNoopMetricsHandle(): MetricsHandle {
|
|
51
|
+
return {
|
|
52
|
+
inc(_name: string, _labels?: MetricLabels, _value?: number): void {},
|
|
53
|
+
observe(_name: string, _value: number, _labels?: MetricLabels): void {},
|
|
54
|
+
set(_name: string, _value: number, _labels?: MetricLabels): void {},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Counter,
|
|
3
|
+
Gauge,
|
|
4
|
+
Histogram,
|
|
5
|
+
Meter,
|
|
6
|
+
MetricDefinition,
|
|
7
|
+
ObservabilityProvider,
|
|
8
|
+
SerializedTraceContext,
|
|
9
|
+
Span,
|
|
10
|
+
SpanStatus,
|
|
11
|
+
StartSpanOptions,
|
|
12
|
+
Tracer,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
// Default provider. Hot-path identical to "observability disabled" — every
|
|
16
|
+
// method is O(1), allocates a tiny object at most, and never calls any IO.
|
|
17
|
+
// Used in tests and as the safe default when no config is provided.
|
|
18
|
+
|
|
19
|
+
class NoopSpan implements Span {
|
|
20
|
+
readonly traceId = "";
|
|
21
|
+
readonly spanId = "";
|
|
22
|
+
readonly parentSpanId: string | undefined;
|
|
23
|
+
readonly name: string;
|
|
24
|
+
private _ended = false;
|
|
25
|
+
|
|
26
|
+
constructor(name: string, parentSpanId: string | undefined) {
|
|
27
|
+
this.name = name;
|
|
28
|
+
this.parentSpanId = parentSpanId;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
setAttribute(_key: string, _value: unknown): void {}
|
|
32
|
+
setAttributes(_attrs: Record<string, unknown>): void {}
|
|
33
|
+
setStatus(_status: SpanStatus, _message?: string): void {}
|
|
34
|
+
recordException(_error: Error): void {}
|
|
35
|
+
end(_endTime?: number): void {
|
|
36
|
+
this._ended = true;
|
|
37
|
+
}
|
|
38
|
+
get ended(): boolean {
|
|
39
|
+
return this._ended;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class NoopTracer implements Tracer {
|
|
44
|
+
startSpan(name: string, options?: StartSpanOptions): Span {
|
|
45
|
+
// `parent` may be either a live Span or a SerializedTraceContext — both
|
|
46
|
+
// carry `spanId`, so a uniform read is safe.
|
|
47
|
+
const parentSpanId = options?.parent?.spanId;
|
|
48
|
+
return new NoopSpan(name, parentSpanId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async withSpan<T>(
|
|
52
|
+
name: string,
|
|
53
|
+
optionsOrFn: StartSpanOptions | ((span: Span) => Promise<T>),
|
|
54
|
+
fn?: (span: Span) => Promise<T>,
|
|
55
|
+
): Promise<T> {
|
|
56
|
+
const actualFn = typeof optionsOrFn === "function" ? optionsOrFn : fn;
|
|
57
|
+
if (!actualFn) {
|
|
58
|
+
throw new Error("withSpan called without callback");
|
|
59
|
+
}
|
|
60
|
+
const span = new NoopSpan(name, undefined);
|
|
61
|
+
try {
|
|
62
|
+
return await actualFn(span);
|
|
63
|
+
} finally {
|
|
64
|
+
span.end();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getActiveSpan(): Span | undefined {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
startSpanFromContext(
|
|
73
|
+
name: string,
|
|
74
|
+
_context: SerializedTraceContext,
|
|
75
|
+
_options?: StartSpanOptions,
|
|
76
|
+
): Span {
|
|
77
|
+
return new NoopSpan(name, undefined);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
class NoopCounter implements Counter {
|
|
82
|
+
inc(_value?: number, _labels?: Record<string, unknown>): void {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
class NoopHistogram implements Histogram {
|
|
86
|
+
observe(_value: number, _labels?: Record<string, unknown>): void {}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
class NoopGauge implements Gauge {
|
|
90
|
+
set(_value: number, _labels?: Record<string, unknown>): void {}
|
|
91
|
+
inc(_value?: number, _labels?: Record<string, unknown>): void {}
|
|
92
|
+
dec(_value?: number, _labels?: Record<string, unknown>): void {}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
class NoopMeter implements Meter {
|
|
96
|
+
private readonly defs = new Map<string, MetricDefinition>();
|
|
97
|
+
private readonly counterInstance = new NoopCounter();
|
|
98
|
+
private readonly histogramInstance = new NoopHistogram();
|
|
99
|
+
private readonly gaugeInstance = new NoopGauge();
|
|
100
|
+
|
|
101
|
+
registerMetric(def: MetricDefinition): void {
|
|
102
|
+
if (this.defs.has(def.name)) {
|
|
103
|
+
throw new Error(`[Kumiko Observability] Metric "${def.name}" already registered.`);
|
|
104
|
+
}
|
|
105
|
+
this.defs.set(def.name, def);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
counter(name: string): Counter {
|
|
109
|
+
const def = this.defs.get(name);
|
|
110
|
+
if (!def || def.type !== "counter") {
|
|
111
|
+
throw new Error(`[Kumiko Observability] Counter "${name}" not registered or wrong type.`);
|
|
112
|
+
}
|
|
113
|
+
return this.counterInstance;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
histogram(name: string): Histogram {
|
|
117
|
+
const def = this.defs.get(name);
|
|
118
|
+
if (!def || def.type !== "histogram") {
|
|
119
|
+
throw new Error(`[Kumiko Observability] Histogram "${name}" not registered or wrong type.`);
|
|
120
|
+
}
|
|
121
|
+
return this.histogramInstance;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
gauge(name: string): Gauge {
|
|
125
|
+
const def = this.defs.get(name);
|
|
126
|
+
if (!def || def.type !== "gauge") {
|
|
127
|
+
throw new Error(`[Kumiko Observability] Gauge "${name}" not registered or wrong type.`);
|
|
128
|
+
}
|
|
129
|
+
return this.gaugeInstance;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
definitions(): ReadonlyMap<string, MetricDefinition> {
|
|
133
|
+
return this.defs;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createNoopProvider(): ObservabilityProvider {
|
|
138
|
+
const tracer = new NoopTracer();
|
|
139
|
+
const meter = new NoopMeter();
|
|
140
|
+
return {
|
|
141
|
+
name: "noop",
|
|
142
|
+
tracer,
|
|
143
|
+
meter,
|
|
144
|
+
async shutdown() {},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Prometheus-scrapeable Meter implementation.
|
|
2
|
+
//
|
|
3
|
+
// The RecordingMeter emits events but doesn't keep rolling totals — it was
|
|
4
|
+
// designed as a pass-through to a user-side provider (console, OTLP,
|
|
5
|
+
// custom). A /metrics endpoint needs the totals materialised, so this
|
|
6
|
+
// module wires the same `Meter` interface to an in-memory accumulator:
|
|
7
|
+
//
|
|
8
|
+
// - counter: sum per labelset
|
|
9
|
+
// - gauge: current value per labelset
|
|
10
|
+
// - histogram: bucket counts + sum + count per labelset
|
|
11
|
+
//
|
|
12
|
+
// `serializeOpenMetrics(meter)` renders the accumulated state into the
|
|
13
|
+
// text format both Prometheus and the OpenMetrics standard accept.
|
|
14
|
+
//
|
|
15
|
+
// Scope limits:
|
|
16
|
+
// - No sliding windows (absolute counters only — the scraper diffs).
|
|
17
|
+
// - No exemplars (OpenMetrics feature, not used by most scrape configs).
|
|
18
|
+
// - No `_created` timestamps on counters — Prometheus-compatible, not
|
|
19
|
+
// fully OpenMetrics-conformant. Most dashboards don't care.
|
|
20
|
+
//
|
|
21
|
+
// If the caller wraps a different Meter alongside (e.g. ConsoleProvider
|
|
22
|
+
// for dev), they can build a composite meter that forwards to both —
|
|
23
|
+
// PrometheusMeter is a leaf, not an aggregator.
|
|
24
|
+
|
|
25
|
+
import { validateLabelKey } from "./metric-validator";
|
|
26
|
+
import type { Counter, Gauge, Histogram, Meter, MetricDefinition, MetricLabels } from "./types";
|
|
27
|
+
|
|
28
|
+
// Default buckets follow Prometheus' histogram convention (seconds-scale).
|
|
29
|
+
// Callers can override per-metric via MetricDefinition.buckets.
|
|
30
|
+
const DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10] as const;
|
|
31
|
+
|
|
32
|
+
// Canonicalise a labels object to a stable key — same labels in different
|
|
33
|
+
// insertion order must hash to the same slot.
|
|
34
|
+
function labelsKey(labels: MetricLabels | undefined): string {
|
|
35
|
+
if (!labels) return "";
|
|
36
|
+
const keys = Object.keys(labels).sort();
|
|
37
|
+
return keys.map((k) => `${k}=${String(labels[k])}`).join(",");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Escape label values per OpenMetrics: backslash, double-quote, newline.
|
|
41
|
+
function escapeLabelValue(v: string | number | boolean): string {
|
|
42
|
+
return String(v).replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderLabels(labels: MetricLabels | undefined): string {
|
|
46
|
+
if (!labels) return "";
|
|
47
|
+
const entries = Object.entries(labels).sort(([a], [b]) => a.localeCompare(b));
|
|
48
|
+
if (entries.length === 0) return "";
|
|
49
|
+
const inner = entries.map(([k, v]) => `${k}="${escapeLabelValue(v)}"`).join(",");
|
|
50
|
+
return `{${inner}}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderLabelsWithExtra(
|
|
54
|
+
labels: MetricLabels | undefined,
|
|
55
|
+
extra: readonly [string, string][],
|
|
56
|
+
): string {
|
|
57
|
+
const entries: [string, string][] = labels
|
|
58
|
+
? Object.entries(labels).map(([k, v]) => [k, String(v)])
|
|
59
|
+
: [];
|
|
60
|
+
for (const [k, v] of extra) entries.push([k, v]);
|
|
61
|
+
entries.sort(([a], [b]) => a.localeCompare(b));
|
|
62
|
+
if (entries.length === 0) return "";
|
|
63
|
+
const inner = entries.map(([k, v]) => `${k}="${escapeLabelValue(v)}"`).join(",");
|
|
64
|
+
return `{${inner}}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type CounterState = { labels: MetricLabels | undefined; value: number };
|
|
68
|
+
type GaugeState = { labels: MetricLabels | undefined; value: number };
|
|
69
|
+
type HistogramState = {
|
|
70
|
+
labels: MetricLabels | undefined;
|
|
71
|
+
buckets: number[]; // cumulative counts, indexed by boundary position
|
|
72
|
+
sum: number;
|
|
73
|
+
count: number;
|
|
74
|
+
boundaries: readonly number[]; // pinned at first observe so late-changes don't skew
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Shared slot-accumulator. counter.inc, gauge.inc, gauge.dec all boil
|
|
78
|
+
// down to "add `delta` to the existing slot or create a new slot with
|
|
79
|
+
// `delta`". The only variance is the sign — extracted once so counter
|
|
80
|
+
// and gauge don't each reimplement the same get-or-create-and-add.
|
|
81
|
+
// CounterState and GaugeState are structurally identical — if that
|
|
82
|
+
// ever diverges, this helper becomes per-type and the call-sites move
|
|
83
|
+
// to their own accumulator.
|
|
84
|
+
function addToSlot(
|
|
85
|
+
slots: Map<string, { labels: MetricLabels | undefined; value: number }>,
|
|
86
|
+
labels: MetricLabels | undefined,
|
|
87
|
+
delta: number,
|
|
88
|
+
): void {
|
|
89
|
+
const key = labelsKey(labels);
|
|
90
|
+
const existing = slots.get(key);
|
|
91
|
+
if (existing) {
|
|
92
|
+
existing.value += delta;
|
|
93
|
+
} else {
|
|
94
|
+
slots.set(key, { labels, value: delta });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
class PrometheusCounter implements Counter {
|
|
99
|
+
constructor(private readonly slots: Map<string, CounterState>) {}
|
|
100
|
+
inc(value?: number, labels?: MetricLabels): void {
|
|
101
|
+
addToSlot(this.slots, labels, value ?? 1);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
class PrometheusGauge implements Gauge {
|
|
106
|
+
constructor(private readonly slots: Map<string, GaugeState>) {}
|
|
107
|
+
set(value: number, labels?: MetricLabels): void {
|
|
108
|
+
// set() overwrites wholesale — can't go through addToSlot which only
|
|
109
|
+
// knows about delta accumulation.
|
|
110
|
+
this.slots.set(labelsKey(labels), { labels, value });
|
|
111
|
+
}
|
|
112
|
+
inc(value?: number, labels?: MetricLabels): void {
|
|
113
|
+
addToSlot(this.slots, labels, value ?? 1);
|
|
114
|
+
}
|
|
115
|
+
dec(value?: number, labels?: MetricLabels): void {
|
|
116
|
+
addToSlot(this.slots, labels, -(value ?? 1));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
class PrometheusHistogram implements Histogram {
|
|
121
|
+
constructor(
|
|
122
|
+
private readonly def: MetricDefinition,
|
|
123
|
+
private readonly slots: Map<string, HistogramState>,
|
|
124
|
+
) {}
|
|
125
|
+
observe(value: number, labels?: MetricLabels): void {
|
|
126
|
+
const key = labelsKey(labels);
|
|
127
|
+
let state = this.slots.get(key);
|
|
128
|
+
if (!state) {
|
|
129
|
+
const boundaries = this.def.buckets ?? DEFAULT_BUCKETS;
|
|
130
|
+
state = {
|
|
131
|
+
labels,
|
|
132
|
+
buckets: new Array(boundaries.length).fill(0),
|
|
133
|
+
sum: 0,
|
|
134
|
+
count: 0,
|
|
135
|
+
boundaries,
|
|
136
|
+
};
|
|
137
|
+
this.slots.set(key, state);
|
|
138
|
+
}
|
|
139
|
+
state.sum += value;
|
|
140
|
+
state.count += 1;
|
|
141
|
+
for (let i = 0; i < state.boundaries.length; i++) {
|
|
142
|
+
// biome-ignore lint/style/noNonNullAssertion: bounded by loop guard
|
|
143
|
+
if (value <= state.boundaries[i]!) {
|
|
144
|
+
// biome-ignore lint/style/noNonNullAssertion: bounded by loop guard
|
|
145
|
+
state.buckets[i]!++;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export type PrometheusMeterSnapshot = ReadonlyMap<
|
|
152
|
+
string,
|
|
153
|
+
{ def: MetricDefinition; slots: ReadonlyArray<CounterState | GaugeState | HistogramState> }
|
|
154
|
+
>;
|
|
155
|
+
|
|
156
|
+
export interface PrometheusMeter extends Meter {
|
|
157
|
+
// Returns the current accumulated state. Used by serializeOpenMetrics
|
|
158
|
+
// — exposed separately so callers can inspect without parsing the text
|
|
159
|
+
// output (handy in tests).
|
|
160
|
+
snapshot(): PrometheusMeterSnapshot;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function createPrometheusMeter(): PrometheusMeter {
|
|
164
|
+
const defs = new Map<string, MetricDefinition>();
|
|
165
|
+
const counterSlots = new Map<string, Map<string, CounterState>>();
|
|
166
|
+
const gaugeSlots = new Map<string, Map<string, GaugeState>>();
|
|
167
|
+
const histogramSlots = new Map<string, Map<string, HistogramState>>();
|
|
168
|
+
const counters = new Map<string, Counter>();
|
|
169
|
+
const gauges = new Map<string, Gauge>();
|
|
170
|
+
const histograms = new Map<string, Histogram>();
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
registerMetric(def) {
|
|
174
|
+
if (defs.has(def.name)) {
|
|
175
|
+
throw new Error(`[Kumiko Observability] Metric "${def.name}" already registered.`);
|
|
176
|
+
}
|
|
177
|
+
for (const label of def.labels ?? []) validateLabelKey(label);
|
|
178
|
+
defs.set(def.name, def);
|
|
179
|
+
if (def.type === "counter") {
|
|
180
|
+
const slots = new Map<string, CounterState>();
|
|
181
|
+
counterSlots.set(def.name, slots);
|
|
182
|
+
counters.set(def.name, new PrometheusCounter(slots));
|
|
183
|
+
} else if (def.type === "gauge") {
|
|
184
|
+
const slots = new Map<string, GaugeState>();
|
|
185
|
+
gaugeSlots.set(def.name, slots);
|
|
186
|
+
gauges.set(def.name, new PrometheusGauge(slots));
|
|
187
|
+
} else {
|
|
188
|
+
const slots = new Map<string, HistogramState>();
|
|
189
|
+
histogramSlots.set(def.name, slots);
|
|
190
|
+
histograms.set(def.name, new PrometheusHistogram(def, slots));
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
counter(name) {
|
|
194
|
+
const c = counters.get(name);
|
|
195
|
+
if (!c)
|
|
196
|
+
throw new Error(`[Kumiko Observability] Counter "${name}" not registered or wrong type.`);
|
|
197
|
+
return c;
|
|
198
|
+
},
|
|
199
|
+
gauge(name) {
|
|
200
|
+
const g = gauges.get(name);
|
|
201
|
+
if (!g)
|
|
202
|
+
throw new Error(`[Kumiko Observability] Gauge "${name}" not registered or wrong type.`);
|
|
203
|
+
return g;
|
|
204
|
+
},
|
|
205
|
+
histogram(name) {
|
|
206
|
+
const h = histograms.get(name);
|
|
207
|
+
if (!h)
|
|
208
|
+
throw new Error(`[Kumiko Observability] Histogram "${name}" not registered or wrong type.`);
|
|
209
|
+
return h;
|
|
210
|
+
},
|
|
211
|
+
definitions() {
|
|
212
|
+
return defs;
|
|
213
|
+
},
|
|
214
|
+
snapshot() {
|
|
215
|
+
const out = new Map<
|
|
216
|
+
string,
|
|
217
|
+
{ def: MetricDefinition; slots: (CounterState | GaugeState | HistogramState)[] }
|
|
218
|
+
>();
|
|
219
|
+
for (const [name, def] of defs) {
|
|
220
|
+
let slots: (CounterState | GaugeState | HistogramState)[];
|
|
221
|
+
if (def.type === "counter") {
|
|
222
|
+
slots = [...(counterSlots.get(name)?.values() ?? [])];
|
|
223
|
+
} else if (def.type === "gauge") {
|
|
224
|
+
slots = [...(gaugeSlots.get(name)?.values() ?? [])];
|
|
225
|
+
} else {
|
|
226
|
+
slots = [...(histogramSlots.get(name)?.values() ?? [])];
|
|
227
|
+
}
|
|
228
|
+
out.set(name, { def, slots });
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// --- OpenMetrics text-format serializer -----------------------------------
|
|
236
|
+
|
|
237
|
+
export function serializeOpenMetrics(meter: PrometheusMeter): string {
|
|
238
|
+
const lines: string[] = [];
|
|
239
|
+
const snap = meter.snapshot();
|
|
240
|
+
// Sort metric names for deterministic output — diff-friendly in tests,
|
|
241
|
+
// Prometheus doesn't care but humans do.
|
|
242
|
+
const names = [...snap.keys()].sort();
|
|
243
|
+
|
|
244
|
+
for (const name of names) {
|
|
245
|
+
const entry = snap.get(name);
|
|
246
|
+
if (!entry) continue;
|
|
247
|
+
const { def, slots } = entry;
|
|
248
|
+
if (def.description) lines.push(`# HELP ${name} ${def.description}`);
|
|
249
|
+
lines.push(`# TYPE ${name} ${def.type}`);
|
|
250
|
+
|
|
251
|
+
// @cast-boundary engine-bridge — slots union narrows by def.type
|
|
252
|
+
if (def.type === "counter") {
|
|
253
|
+
for (const s of slots as CounterState[]) {
|
|
254
|
+
lines.push(`${name}${renderLabels(s.labels)} ${s.value}`);
|
|
255
|
+
}
|
|
256
|
+
} else if (def.type === "gauge") {
|
|
257
|
+
for (const s of slots as GaugeState[]) {
|
|
258
|
+
lines.push(`${name}${renderLabels(s.labels)} ${s.value}`);
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
for (const s of slots as HistogramState[]) {
|
|
262
|
+
// Cumulative bucket counts + +Inf terminator + sum/count suffixes.
|
|
263
|
+
let cumulative = 0;
|
|
264
|
+
for (let i = 0; i < s.boundaries.length; i++) {
|
|
265
|
+
// biome-ignore lint/style/noNonNullAssertion: bounded by loop guard
|
|
266
|
+
cumulative = s.buckets[i]!;
|
|
267
|
+
// biome-ignore lint/style/noNonNullAssertion: bounded by loop guard
|
|
268
|
+
const le = String(s.boundaries[i]!);
|
|
269
|
+
lines.push(
|
|
270
|
+
`${name}_bucket${renderLabelsWithExtra(s.labels, [["le", le]])} ${cumulative}`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
lines.push(`${name}_bucket${renderLabelsWithExtra(s.labels, [["le", "+Inf"]])} ${s.count}`);
|
|
274
|
+
lines.push(`${name}_sum${renderLabels(s.labels)} ${s.sum}`);
|
|
275
|
+
lines.push(`${name}_count${renderLabels(s.labels)} ${s.count}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// OpenMetrics requires a trailing newline + `# EOF` — Prometheus ignores
|
|
281
|
+
// but conformant scrapers rely on it.
|
|
282
|
+
lines.push("# EOF");
|
|
283
|
+
return `${lines.join("\n")}\n`;
|
|
284
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { assertUnreachable } from "../utils";
|
|
2
|
+
import { validateLabelKey } from "./metric-validator";
|
|
3
|
+
import type { Counter, Gauge, Histogram, Meter, MetricDefinition, MetricLabels } from "./types";
|
|
4
|
+
|
|
5
|
+
// Event type emitted when any metric changes — feeds into provider emitters.
|
|
6
|
+
export type MetricEvent =
|
|
7
|
+
| {
|
|
8
|
+
readonly type: "counter.inc";
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly value: number;
|
|
11
|
+
readonly labels: MetricLabels | undefined;
|
|
12
|
+
}
|
|
13
|
+
| {
|
|
14
|
+
readonly type: "histogram.observe";
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly value: number;
|
|
17
|
+
readonly labels: MetricLabels | undefined;
|
|
18
|
+
}
|
|
19
|
+
| {
|
|
20
|
+
readonly type: "gauge.set" | "gauge.inc" | "gauge.dec";
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly value: number;
|
|
23
|
+
readonly labels: MetricLabels | undefined;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type MetricEventHandler = (event: MetricEvent) => void;
|
|
27
|
+
|
|
28
|
+
// Validate provided labels against the declared label keys.
|
|
29
|
+
// Unknown or missing keys throw — typed metrics only.
|
|
30
|
+
function validateLabels(def: MetricDefinition, labels?: MetricLabels): void {
|
|
31
|
+
const declared = new Set(def.labels ?? []);
|
|
32
|
+
if (def.tenantLabel) declared.add("tenant_id");
|
|
33
|
+
if (!labels) {
|
|
34
|
+
if (declared.size > 0) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`[Kumiko Observability] Metric "${def.name}" expects labels ${[...declared].join(", ")} but got none.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
// skip: metric has no declared labels and none were passed — valid call
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
for (const key of Object.keys(labels)) {
|
|
43
|
+
if (!declared.has(key)) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`[Kumiko Observability] Metric "${def.name}" got unknown label "${key}". ` +
|
|
46
|
+
`Allowed: ${[...declared].join(", ") || "(none)"}.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const key of declared) {
|
|
51
|
+
if (!(key in labels)) {
|
|
52
|
+
throw new Error(`[Kumiko Observability] Metric "${def.name}" missing label "${key}".`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
class RecordingCounter implements Counter {
|
|
58
|
+
constructor(
|
|
59
|
+
private readonly def: MetricDefinition,
|
|
60
|
+
private readonly emit: MetricEventHandler,
|
|
61
|
+
) {}
|
|
62
|
+
inc(value?: number, labels?: MetricLabels): void {
|
|
63
|
+
validateLabels(this.def, labels);
|
|
64
|
+
this.emit({ type: "counter.inc", name: this.def.name, value: value ?? 1, labels });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class RecordingHistogram implements Histogram {
|
|
69
|
+
constructor(
|
|
70
|
+
private readonly def: MetricDefinition,
|
|
71
|
+
private readonly emit: MetricEventHandler,
|
|
72
|
+
) {}
|
|
73
|
+
observe(value: number, labels?: MetricLabels): void {
|
|
74
|
+
validateLabels(this.def, labels);
|
|
75
|
+
this.emit({ type: "histogram.observe", name: this.def.name, value, labels });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
class RecordingGauge implements Gauge {
|
|
80
|
+
constructor(
|
|
81
|
+
private readonly def: MetricDefinition,
|
|
82
|
+
private readonly emit: MetricEventHandler,
|
|
83
|
+
) {}
|
|
84
|
+
set(value: number, labels?: MetricLabels): void {
|
|
85
|
+
validateLabels(this.def, labels);
|
|
86
|
+
this.emit({ type: "gauge.set", name: this.def.name, value, labels });
|
|
87
|
+
}
|
|
88
|
+
inc(value?: number, labels?: MetricLabels): void {
|
|
89
|
+
validateLabels(this.def, labels);
|
|
90
|
+
this.emit({ type: "gauge.inc", name: this.def.name, value: value ?? 1, labels });
|
|
91
|
+
}
|
|
92
|
+
dec(value?: number, labels?: MetricLabels): void {
|
|
93
|
+
validateLabels(this.def, labels);
|
|
94
|
+
this.emit({ type: "gauge.dec", name: this.def.name, value: value ?? 1, labels });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class RecordingMeter implements Meter {
|
|
99
|
+
private readonly defs = new Map<string, MetricDefinition>();
|
|
100
|
+
private readonly counters = new Map<string, Counter>();
|
|
101
|
+
private readonly histograms = new Map<string, Histogram>();
|
|
102
|
+
private readonly gauges = new Map<string, Gauge>();
|
|
103
|
+
|
|
104
|
+
constructor(private readonly emit: MetricEventHandler) {}
|
|
105
|
+
|
|
106
|
+
registerMetric(def: MetricDefinition): void {
|
|
107
|
+
if (this.defs.has(def.name)) {
|
|
108
|
+
throw new Error(`[Kumiko Observability] Metric "${def.name}" already registered.`);
|
|
109
|
+
}
|
|
110
|
+
for (const label of def.labels ?? []) {
|
|
111
|
+
validateLabelKey(label);
|
|
112
|
+
}
|
|
113
|
+
this.defs.set(def.name, def);
|
|
114
|
+
switch (def.type) {
|
|
115
|
+
case "counter":
|
|
116
|
+
this.counters.set(def.name, new RecordingCounter(def, this.emit));
|
|
117
|
+
break;
|
|
118
|
+
case "histogram":
|
|
119
|
+
this.histograms.set(def.name, new RecordingHistogram(def, this.emit));
|
|
120
|
+
break;
|
|
121
|
+
case "gauge":
|
|
122
|
+
this.gauges.set(def.name, new RecordingGauge(def, this.emit));
|
|
123
|
+
break;
|
|
124
|
+
default:
|
|
125
|
+
assertUnreachable(def.type, "metric type");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
counter(name: string): Counter {
|
|
130
|
+
const c = this.counters.get(name);
|
|
131
|
+
if (!c) {
|
|
132
|
+
throw new Error(`[Kumiko Observability] Counter "${name}" not registered or wrong type.`);
|
|
133
|
+
}
|
|
134
|
+
return c;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
histogram(name: string): Histogram {
|
|
138
|
+
const h = this.histograms.get(name);
|
|
139
|
+
if (!h) {
|
|
140
|
+
throw new Error(`[Kumiko Observability] Histogram "${name}" not registered or wrong type.`);
|
|
141
|
+
}
|
|
142
|
+
return h;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
gauge(name: string): Gauge {
|
|
146
|
+
const g = this.gauges.get(name);
|
|
147
|
+
if (!g) {
|
|
148
|
+
throw new Error(`[Kumiko Observability] Gauge "${name}" not registered or wrong type.`);
|
|
149
|
+
}
|
|
150
|
+
return g;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
definitions(): ReadonlyMap<string, MetricDefinition> {
|
|
154
|
+
return this.defs;
|
|
155
|
+
}
|
|
156
|
+
}
|