@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,101 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { type MetricEvent, RecordingMeter } from "../recording-meter";
|
|
3
|
+
|
|
4
|
+
function makeMeter() {
|
|
5
|
+
const events: MetricEvent[] = [];
|
|
6
|
+
const meter = new RecordingMeter((e) => events.push(e));
|
|
7
|
+
return { meter, events };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("RecordingMeter", () => {
|
|
11
|
+
it("counter.inc emits event with default value 1", () => {
|
|
12
|
+
const { meter, events } = makeMeter();
|
|
13
|
+
meter.registerMetric({ name: "kumiko_orders_created_total", type: "counter" });
|
|
14
|
+
meter.counter("kumiko_orders_created_total").inc();
|
|
15
|
+
expect(events).toEqual([
|
|
16
|
+
{ type: "counter.inc", name: "kumiko_orders_created_total", value: 1, labels: undefined },
|
|
17
|
+
]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("histogram.observe emits event with explicit value", () => {
|
|
21
|
+
const { meter, events } = makeMeter();
|
|
22
|
+
meter.registerMetric({
|
|
23
|
+
name: "kumiko_http_request_duration_seconds",
|
|
24
|
+
type: "histogram",
|
|
25
|
+
});
|
|
26
|
+
meter.histogram("kumiko_http_request_duration_seconds").observe(0.123);
|
|
27
|
+
expect(events[0]).toMatchObject({ type: "histogram.observe", value: 0.123 });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("gauge.set / inc / dec emit typed events", () => {
|
|
31
|
+
const { meter, events } = makeMeter();
|
|
32
|
+
meter.registerMetric({ name: "kumiko_sessions_active", type: "gauge" });
|
|
33
|
+
const g = meter.gauge("kumiko_sessions_active");
|
|
34
|
+
g.set(10);
|
|
35
|
+
g.inc();
|
|
36
|
+
g.dec(3);
|
|
37
|
+
expect(events.map((e) => e.type)).toEqual(["gauge.set", "gauge.inc", "gauge.dec"]);
|
|
38
|
+
expect(events.map((e) => e.value)).toEqual([10, 1, 3]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("label validation: unknown label throws", () => {
|
|
42
|
+
const { meter } = makeMeter();
|
|
43
|
+
meter.registerMetric({
|
|
44
|
+
name: "kumiko_orders_created_total",
|
|
45
|
+
type: "counter",
|
|
46
|
+
labels: ["status"],
|
|
47
|
+
});
|
|
48
|
+
expect(() =>
|
|
49
|
+
meter
|
|
50
|
+
.counter("kumiko_orders_created_total")
|
|
51
|
+
.inc(1, { unknown: "x" } as unknown as Record<string, string>),
|
|
52
|
+
).toThrow(/unknown label "unknown"/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("label validation: missing label throws", () => {
|
|
56
|
+
const { meter } = makeMeter();
|
|
57
|
+
meter.registerMetric({
|
|
58
|
+
name: "kumiko_orders_created_total",
|
|
59
|
+
type: "counter",
|
|
60
|
+
labels: ["status"],
|
|
61
|
+
});
|
|
62
|
+
expect(() => meter.counter("kumiko_orders_created_total").inc()).toThrow(
|
|
63
|
+
/expects labels status/,
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("registerMetric validates label-key snake_case", () => {
|
|
68
|
+
const { meter } = makeMeter();
|
|
69
|
+
expect(() =>
|
|
70
|
+
meter.registerMetric({
|
|
71
|
+
name: "kumiko_x_total",
|
|
72
|
+
type: "counter",
|
|
73
|
+
labels: ["errorClass"],
|
|
74
|
+
}),
|
|
75
|
+
).toThrow(/snake_case/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("tenantLabel adds tenant_id to declared labels", () => {
|
|
79
|
+
const { meter } = makeMeter();
|
|
80
|
+
meter.registerMetric({
|
|
81
|
+
name: "kumiko_orders_created_total",
|
|
82
|
+
type: "counter",
|
|
83
|
+
labels: ["status"],
|
|
84
|
+
tenantLabel: true,
|
|
85
|
+
});
|
|
86
|
+
expect(() => meter.counter("kumiko_orders_created_total").inc(1, { status: "new" })).toThrow(
|
|
87
|
+
/missing label "tenant_id"/,
|
|
88
|
+
);
|
|
89
|
+
expect(() =>
|
|
90
|
+
meter.counter("kumiko_orders_created_total").inc(1, { status: "new", tenant_id: 1 }),
|
|
91
|
+
).not.toThrow();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("duplicate registration throws", () => {
|
|
95
|
+
const { meter } = makeMeter();
|
|
96
|
+
meter.registerMetric({ name: "kumiko_x_total", type: "counter" });
|
|
97
|
+
expect(() => meter.registerMetric({ name: "kumiko_x_total", type: "counter" })).toThrow(
|
|
98
|
+
/already registered/,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { type RecordedSpan, RecordingTracer } from "../recording-tracer";
|
|
3
|
+
import { DEFAULT_SENSITIVE_CONFIG } from "../sensitive-filter";
|
|
4
|
+
|
|
5
|
+
function makeTracer() {
|
|
6
|
+
const recorded: RecordedSpan[] = [];
|
|
7
|
+
const tracer = new RecordingTracer({
|
|
8
|
+
sensitiveConfig: DEFAULT_SENSITIVE_CONFIG,
|
|
9
|
+
onSpanEnd: (s) => recorded.push(s),
|
|
10
|
+
});
|
|
11
|
+
return { tracer, recorded };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("RecordingTracer", () => {
|
|
15
|
+
it("startSpan generates hex-encoded OTel-shaped IDs", () => {
|
|
16
|
+
const { tracer } = makeTracer();
|
|
17
|
+
const span = tracer.startSpan("test");
|
|
18
|
+
expect(span.traceId).toMatch(/^[0-9a-f]{32}$/);
|
|
19
|
+
expect(span.spanId).toMatch(/^[0-9a-f]{16}$/);
|
|
20
|
+
span.end();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("child span inherits traceId and references parent spanId", async () => {
|
|
24
|
+
const { tracer, recorded } = makeTracer();
|
|
25
|
+
await tracer.withSpan("root", async () => {
|
|
26
|
+
await tracer.withSpan("child", async () => {});
|
|
27
|
+
});
|
|
28
|
+
const root = recorded.find((s) => s.name === "root")!;
|
|
29
|
+
const child = recorded.find((s) => s.name === "child")!;
|
|
30
|
+
expect(child.traceId).toBe(root.traceId);
|
|
31
|
+
expect(child.parentSpanId).toBe(root.spanId);
|
|
32
|
+
expect(root.parentSpanId).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("sibling spans share traceId but have different spanIds", async () => {
|
|
36
|
+
const { tracer, recorded } = makeTracer();
|
|
37
|
+
await tracer.withSpan("root", async () => {
|
|
38
|
+
await tracer.withSpan("a", async () => {});
|
|
39
|
+
await tracer.withSpan("b", async () => {});
|
|
40
|
+
});
|
|
41
|
+
const a = recorded.find((s) => s.name === "a")!;
|
|
42
|
+
const b = recorded.find((s) => s.name === "b")!;
|
|
43
|
+
expect(a.traceId).toBe(b.traceId);
|
|
44
|
+
expect(a.spanId).not.toBe(b.spanId);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("withSpan ends span and records exception on throw", async () => {
|
|
48
|
+
const { tracer, recorded } = makeTracer();
|
|
49
|
+
await expect(
|
|
50
|
+
tracer.withSpan("boom", async () => {
|
|
51
|
+
throw new Error("kaboom");
|
|
52
|
+
}),
|
|
53
|
+
).rejects.toThrow("kaboom");
|
|
54
|
+
const span = recorded[0]!;
|
|
55
|
+
expect(span.status).toBe("error");
|
|
56
|
+
expect(span.exception?.message).toBe("kaboom");
|
|
57
|
+
expect(span.endTime).toBeDefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("getActiveSpan returns current span inside withSpan", async () => {
|
|
61
|
+
const { tracer } = makeTracer();
|
|
62
|
+
await tracer.withSpan("outer", async () => {
|
|
63
|
+
expect(tracer.getActiveSpan()?.name).toBe("outer");
|
|
64
|
+
await tracer.withSpan("inner", async () => {
|
|
65
|
+
expect(tracer.getActiveSpan()?.name).toBe("inner");
|
|
66
|
+
});
|
|
67
|
+
expect(tracer.getActiveSpan()?.name).toBe("outer");
|
|
68
|
+
});
|
|
69
|
+
expect(tracer.getActiveSpan()).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("setAttribute redacts sensitive keys", () => {
|
|
73
|
+
const { tracer, recorded } = makeTracer();
|
|
74
|
+
const span = tracer.startSpan("s");
|
|
75
|
+
span.setAttribute("user.password", "hunter2");
|
|
76
|
+
span.setAttribute("user.id", 42);
|
|
77
|
+
span.end();
|
|
78
|
+
expect(recorded[0]?.attributes["user.password"]).toBe("[REDACTED]");
|
|
79
|
+
expect(recorded[0]?.attributes["user.id"]).toBe(42);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("end is idempotent", () => {
|
|
83
|
+
const { tracer, recorded } = makeTracer();
|
|
84
|
+
const span = tracer.startSpan("s");
|
|
85
|
+
span.end();
|
|
86
|
+
span.end();
|
|
87
|
+
expect(recorded).toHaveLength(1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("startSpanFromContext continues an upstream trace", () => {
|
|
91
|
+
const { tracer, recorded } = makeTracer();
|
|
92
|
+
const span = tracer.startSpanFromContext("child", {
|
|
93
|
+
traceId: "aabbccddeeff00112233445566778899",
|
|
94
|
+
spanId: "1122334455667788",
|
|
95
|
+
});
|
|
96
|
+
span.end();
|
|
97
|
+
expect(recorded[0]?.traceId).toBe("aabbccddeeff00112233445566778899");
|
|
98
|
+
expect(recorded[0]?.parentSpanId).toBe("1122334455667788");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("withSpan supports callback-only form", async () => {
|
|
102
|
+
const { tracer, recorded } = makeTracer();
|
|
103
|
+
const value = await tracer.withSpan("x", async (span) => {
|
|
104
|
+
expect(span.name).toBe("x");
|
|
105
|
+
return 123;
|
|
106
|
+
});
|
|
107
|
+
expect(value).toBe(123);
|
|
108
|
+
expect(recorded).toHaveLength(1);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_SENSITIVE_CONFIG,
|
|
4
|
+
mergeSensitiveConfig,
|
|
5
|
+
REDACTED,
|
|
6
|
+
redactAttributes,
|
|
7
|
+
redactHeaders,
|
|
8
|
+
redactQueryString,
|
|
9
|
+
shouldRedactAttribute,
|
|
10
|
+
} from "../sensitive-filter";
|
|
11
|
+
|
|
12
|
+
describe("redactHeaders", () => {
|
|
13
|
+
it("redacts default sensitive headers case-insensitive", () => {
|
|
14
|
+
const result = redactHeaders(
|
|
15
|
+
{
|
|
16
|
+
Authorization: "Bearer abc",
|
|
17
|
+
Cookie: "session=xyz",
|
|
18
|
+
"X-API-Key": "secret",
|
|
19
|
+
"X-Request-ID": "req-123",
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
},
|
|
22
|
+
DEFAULT_SENSITIVE_CONFIG,
|
|
23
|
+
);
|
|
24
|
+
expect(result["Authorization"]).toBe(REDACTED);
|
|
25
|
+
expect(result["Cookie"]).toBe(REDACTED);
|
|
26
|
+
expect(result["X-API-Key"]).toBe(REDACTED);
|
|
27
|
+
expect(result["X-Request-ID"]).toBe("req-123");
|
|
28
|
+
expect(result["Content-Type"]).toBe("application/json");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("keeps other headers unchanged", () => {
|
|
32
|
+
const result = redactHeaders({ "user-agent": "Mozilla" }, DEFAULT_SENSITIVE_CONFIG);
|
|
33
|
+
expect(result["user-agent"]).toBe("Mozilla");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("redactQueryString", () => {
|
|
38
|
+
it("redacts tokens in path+query form", () => {
|
|
39
|
+
const result = redactQueryString("/api/callback?token=abc&user=bob", DEFAULT_SENSITIVE_CONFIG);
|
|
40
|
+
expect(result).toContain(`token=${encodeURIComponent(REDACTED)}`);
|
|
41
|
+
expect(result).toContain("user=bob");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("handles absolute URLs", () => {
|
|
45
|
+
const result = redactQueryString(
|
|
46
|
+
"https://example.com/oauth?access_token=xyz&state=ok",
|
|
47
|
+
DEFAULT_SENSITIVE_CONFIG,
|
|
48
|
+
);
|
|
49
|
+
expect(result).toContain(`access_token=${encodeURIComponent(REDACTED)}`);
|
|
50
|
+
expect(result).toContain("state=ok");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("preserves path and fragment", () => {
|
|
54
|
+
const result = redactQueryString("/path/to/thing?password=x#section", DEFAULT_SENSITIVE_CONFIG);
|
|
55
|
+
expect(result.startsWith("/path/to/thing")).toBe(true);
|
|
56
|
+
expect(result.endsWith("#section")).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("shouldRedactAttribute + redactAttributes", () => {
|
|
61
|
+
it("matches password-like keys case-insensitive", () => {
|
|
62
|
+
expect(shouldRedactAttribute("user.password", DEFAULT_SENSITIVE_CONFIG)).toBe(true);
|
|
63
|
+
expect(shouldRedactAttribute("apiToken", DEFAULT_SENSITIVE_CONFIG)).toBe(true);
|
|
64
|
+
expect(shouldRedactAttribute("SessionId", DEFAULT_SENSITIVE_CONFIG)).toBe(true);
|
|
65
|
+
expect(shouldRedactAttribute("orderCount", DEFAULT_SENSITIVE_CONFIG)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("redacts sensitive attribute keys", () => {
|
|
69
|
+
const out = redactAttributes(
|
|
70
|
+
{ "user.password": "hunter2", "user.id": 42, privateKey: "-----" },
|
|
71
|
+
DEFAULT_SENSITIVE_CONFIG,
|
|
72
|
+
);
|
|
73
|
+
// Strings redact to REDACTED marker, numbers/booleans redact type-preserving.
|
|
74
|
+
expect(out["user.password"]).toBe(REDACTED);
|
|
75
|
+
expect(out["privateKey"]).toBe(REDACTED);
|
|
76
|
+
expect(out["user.id"]).toBe(42);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("redactValue preserves type while neutralising the value", async () => {
|
|
80
|
+
const { redactValue } = await import("../sensitive-filter");
|
|
81
|
+
expect(redactValue("secret")).toBe(REDACTED);
|
|
82
|
+
expect(redactValue(42)).toBe(0);
|
|
83
|
+
expect(redactValue(true)).toBe(false);
|
|
84
|
+
expect(redactValue(false)).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("mergeSensitiveConfig", () => {
|
|
89
|
+
it("returns default when override is undefined", () => {
|
|
90
|
+
expect(mergeSensitiveConfig(undefined)).toBe(DEFAULT_SENSITIVE_CONFIG);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("merges partial overrides", () => {
|
|
94
|
+
const merged = mergeSensitiveConfig({ redactedHeaders: ["x-custom"] });
|
|
95
|
+
expect(merged.redactedHeaders).toEqual(["x-custom"]);
|
|
96
|
+
expect(merged.redactedQueryParams).toBe(DEFAULT_SENSITIVE_CONFIG.redactedQueryParams);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { type MetricEvent, RecordingMeter } from "./recording-meter";
|
|
2
|
+
import { type RecordedSpan, RecordingTracer } from "./recording-tracer";
|
|
3
|
+
import { DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig } from "./sensitive-filter";
|
|
4
|
+
import type { ObservabilityOptions, ObservabilityProvider } from "./types";
|
|
5
|
+
|
|
6
|
+
type ConsoleWriter = {
|
|
7
|
+
readonly log: (line: string) => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ConsoleProviderOptions = ObservabilityOptions & {
|
|
11
|
+
readonly writer?: ConsoleWriter;
|
|
12
|
+
// If true, buffer spans until the root span ends and then print the full
|
|
13
|
+
// tree at once. If false, each span prints as it ends. Default true —
|
|
14
|
+
// tree output is dramatically more readable.
|
|
15
|
+
readonly bufferUntilRoot?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Pretty-print a span-tree rooted at `root` into a multi-line string.
|
|
19
|
+
function renderTree(
|
|
20
|
+
root: RecordedSpan,
|
|
21
|
+
children: ReadonlyMap<string | undefined, readonly RecordedSpan[]>,
|
|
22
|
+
): string {
|
|
23
|
+
const lines: string[] = [];
|
|
24
|
+
const render = (span: RecordedSpan, prefix: string, isLast: boolean, isRoot: boolean) => {
|
|
25
|
+
const connector = isRoot ? "" : isLast ? "└─ " : "├─ ";
|
|
26
|
+
const duration =
|
|
27
|
+
span.endTime !== undefined
|
|
28
|
+
? `${(span.endTime - span.startTime).toFixed(1)}ms`
|
|
29
|
+
: "(unfinished)";
|
|
30
|
+
const statusTag = span.status === "error" ? " [ERR]" : span.status === "ok" ? "" : "";
|
|
31
|
+
lines.push(`${prefix}${connector}${span.name} (${duration})${statusTag}`);
|
|
32
|
+
|
|
33
|
+
const attrKeys = Object.keys(span.attributes);
|
|
34
|
+
const nextPrefix = isRoot ? " " : `${prefix}${isLast ? " " : "│ "}`;
|
|
35
|
+
if (attrKeys.length > 0) {
|
|
36
|
+
for (const key of attrKeys) {
|
|
37
|
+
const value = span.attributes[key];
|
|
38
|
+
lines.push(`${nextPrefix} ${key}=${String(value)}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (span.exception) {
|
|
42
|
+
lines.push(`${nextPrefix} !exception=${span.exception.name}: ${span.exception.message}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const kids = children.get(span.spanId) ?? [];
|
|
46
|
+
kids.forEach((child, i) => {
|
|
47
|
+
render(child, nextPrefix, i === kids.length - 1, false);
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
render(root, "", true, true);
|
|
51
|
+
return lines.join("\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function groupByParent(
|
|
55
|
+
spans: readonly RecordedSpan[],
|
|
56
|
+
): ReadonlyMap<string | undefined, readonly RecordedSpan[]> {
|
|
57
|
+
const map = new Map<string | undefined, RecordedSpan[]>();
|
|
58
|
+
for (const s of spans) {
|
|
59
|
+
const list = map.get(s.parentSpanId) ?? [];
|
|
60
|
+
list.push(s);
|
|
61
|
+
map.set(s.parentSpanId, list);
|
|
62
|
+
}
|
|
63
|
+
// Sort children by startTime for stable output.
|
|
64
|
+
for (const [, list] of map) {
|
|
65
|
+
list.sort((a, b) => a.startTime - b.startTime);
|
|
66
|
+
}
|
|
67
|
+
return map;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createConsoleProvider(options: ConsoleProviderOptions = {}): ObservabilityProvider {
|
|
71
|
+
const writer = options.writer ?? { log: (_line) => {} };
|
|
72
|
+
const sensitiveConfig = mergeSensitiveConfig(options.sensitiveFilter ?? DEFAULT_SENSITIVE_CONFIG);
|
|
73
|
+
const bufferUntilRoot = options.bufferUntilRoot !== false;
|
|
74
|
+
|
|
75
|
+
// Per-trace buffer. Once the root (parentSpanId === undefined) ends, we
|
|
76
|
+
// render and flush. Child spans that arrive after the root ends are
|
|
77
|
+
// printed immediately as orphans — shouldn't happen in practice but
|
|
78
|
+
// safer than losing them.
|
|
79
|
+
const buffer = new Map<string, RecordedSpan[]>();
|
|
80
|
+
const rootSeen = new Map<string, RecordedSpan>();
|
|
81
|
+
|
|
82
|
+
const handleSpanEnd = (span: RecordedSpan) => {
|
|
83
|
+
if (!bufferUntilRoot) {
|
|
84
|
+
writer.log(renderTree(span, new Map([[span.parentSpanId, []]])));
|
|
85
|
+
// skip: flat-print mode already emitted the span, no tree buffering needed
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const bucket = buffer.get(span.traceId) ?? [];
|
|
89
|
+
bucket.push(span);
|
|
90
|
+
buffer.set(span.traceId, bucket);
|
|
91
|
+
if (span.parentSpanId === undefined) {
|
|
92
|
+
rootSeen.set(span.traceId, span);
|
|
93
|
+
}
|
|
94
|
+
const root = rootSeen.get(span.traceId);
|
|
95
|
+
if (root && span.spanId === root.spanId) {
|
|
96
|
+
const children = groupByParent(bucket);
|
|
97
|
+
writer.log(renderTree(root, children));
|
|
98
|
+
buffer.delete(span.traceId);
|
|
99
|
+
rootSeen.delete(span.traceId);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleMetric = (event: MetricEvent) => {
|
|
104
|
+
const labelStr = event.labels
|
|
105
|
+
? ` {${Object.entries(event.labels)
|
|
106
|
+
.map(([k, v]) => `${k}=${String(v)}`)
|
|
107
|
+
.join(",")}}`
|
|
108
|
+
: "";
|
|
109
|
+
writer.log(`[metric] ${event.type} ${event.name}${labelStr} value=${event.value}`);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const tracer = new RecordingTracer({ sensitiveConfig, onSpanEnd: handleSpanEnd });
|
|
113
|
+
const meter = new RecordingMeter(handleMetric);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
name: "console",
|
|
117
|
+
tracer,
|
|
118
|
+
meter,
|
|
119
|
+
async shutdown() {
|
|
120
|
+
// Flush any orphaned spans (trace whose root never arrived).
|
|
121
|
+
for (const [traceId, bucket] of buffer) {
|
|
122
|
+
for (const s of bucket) {
|
|
123
|
+
writer.log(`[orphan-span ${traceId}] ${s.name}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
buffer.clear();
|
|
127
|
+
rootSeen.clear();
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import type { Span } from "./types";
|
|
3
|
+
|
|
4
|
+
// Separate ALS from requestContext so observability stays optional — the
|
|
5
|
+
// request-id pipeline doesn't need to know about spans, and the span stack
|
|
6
|
+
// doesn't need to know about request-ids. Both run alongside each other.
|
|
7
|
+
|
|
8
|
+
type ObservabilityContextData = {
|
|
9
|
+
readonly activeSpan?: Span;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const storage = new AsyncLocalStorage<ObservabilityContextData>();
|
|
13
|
+
|
|
14
|
+
export const observabilityContext = {
|
|
15
|
+
run<T>(data: ObservabilityContextData, fn: () => T): T {
|
|
16
|
+
return storage.run(data, fn);
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
get(): ObservabilityContextData | undefined {
|
|
20
|
+
return storage.getStore();
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
getActiveSpan(): Span | undefined {
|
|
24
|
+
return storage.getStore()?.activeSpan;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createNoopProvider } from "./noop-provider";
|
|
2
|
+
import { registerStandardMetrics } from "./standard-metrics";
|
|
3
|
+
import type { Meter, ObservabilityProvider, Tracer } from "./types";
|
|
4
|
+
|
|
5
|
+
// Lazy fallback provider for call-sites that construct pipeline components
|
|
6
|
+
// (dispatcher, lifecycle-pipeline, job-runner) directly without going
|
|
7
|
+
// through buildServer. Shared singleton — allocating one NoopProvider per
|
|
8
|
+
// module would work too, but this keeps memory flat and lets us verify in
|
|
9
|
+
// tests that unconfigured meters don't accumulate state.
|
|
10
|
+
//
|
|
11
|
+
// Standard metrics are registered on first access so that emitters
|
|
12
|
+
// (emitDispatcherHandler, emitEventConsumerLag, ...) don't throw "gauge
|
|
13
|
+
// not registered" when the caller skipped buildServer. The NoopMeter's
|
|
14
|
+
// strict registration check catches typos in named-metric code at test
|
|
15
|
+
// time — we keep that guarantee for named feature metrics while making
|
|
16
|
+
// the framework's own standard metrics always safe to emit.
|
|
17
|
+
|
|
18
|
+
let provider: ObservabilityProvider | undefined;
|
|
19
|
+
|
|
20
|
+
export function getFallbackProvider(): ObservabilityProvider {
|
|
21
|
+
if (!provider) {
|
|
22
|
+
provider = createNoopProvider();
|
|
23
|
+
registerStandardMetrics(provider.meter);
|
|
24
|
+
}
|
|
25
|
+
return provider;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getFallbackTracer(): Tracer {
|
|
29
|
+
return getFallbackProvider().tracer;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getFallbackMeter(): Meter {
|
|
33
|
+
return getFallbackProvider().meter;
|
|
34
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// OTel trace and span ID generation. Sticks to the spec shape so external
|
|
2
|
+
// collectors and UIs understand what we emit:
|
|
3
|
+
// - traceId: 16 random bytes, rendered as 32 lowercase hex chars
|
|
4
|
+
// - spanId: 8 random bytes, rendered as 16 lowercase hex chars
|
|
5
|
+
|
|
6
|
+
function bytesToHex(bytes: Uint8Array): string {
|
|
7
|
+
let out = "";
|
|
8
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
9
|
+
const b = bytes[i] ?? 0;
|
|
10
|
+
out += b.toString(16).padStart(2, "0");
|
|
11
|
+
}
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function generateTraceId(): string {
|
|
16
|
+
const bytes = new Uint8Array(16);
|
|
17
|
+
crypto.getRandomValues(bytes);
|
|
18
|
+
return bytesToHex(bytes);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function generateSpanId(): string {
|
|
22
|
+
const bytes = new Uint8Array(8);
|
|
23
|
+
crypto.getRandomValues(bytes);
|
|
24
|
+
return bytesToHex(bytes);
|
|
25
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Public surface of the observability module.
|
|
2
|
+
|
|
3
|
+
export { type ConsoleProviderOptions, createConsoleProvider } from "./console-provider";
|
|
4
|
+
|
|
5
|
+
export { observabilityContext } from "./context";
|
|
6
|
+
export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback";
|
|
7
|
+
export { generateSpanId, generateTraceId } from "./ids";
|
|
8
|
+
export {
|
|
9
|
+
buildMetricName,
|
|
10
|
+
validateLabelKey,
|
|
11
|
+
validateMetricName,
|
|
12
|
+
} from "./metric-validator";
|
|
13
|
+
export {
|
|
14
|
+
createMetricsHandle,
|
|
15
|
+
createNoopMetricsHandle,
|
|
16
|
+
createUnboundMetricsHandle,
|
|
17
|
+
} from "./metrics-handle";
|
|
18
|
+
export { createNoopProvider } from "./noop-provider";
|
|
19
|
+
export {
|
|
20
|
+
createPrometheusMeter,
|
|
21
|
+
type PrometheusMeter,
|
|
22
|
+
type PrometheusMeterSnapshot,
|
|
23
|
+
serializeOpenMetrics,
|
|
24
|
+
} from "./prometheus-meter";
|
|
25
|
+
export {
|
|
26
|
+
type MetricEvent,
|
|
27
|
+
type MetricEventHandler,
|
|
28
|
+
RecordingMeter,
|
|
29
|
+
} from "./recording-meter";
|
|
30
|
+
export {
|
|
31
|
+
type RecordedSpan,
|
|
32
|
+
RecordingTracer,
|
|
33
|
+
type RecordingTracerOptions,
|
|
34
|
+
serializeSpanContext,
|
|
35
|
+
} from "./recording-tracer";
|
|
36
|
+
export { wrapRedisClient } from "./redis-wrapper";
|
|
37
|
+
export {
|
|
38
|
+
DEFAULT_SENSITIVE_CONFIG,
|
|
39
|
+
mergeSensitiveConfig,
|
|
40
|
+
REDACTED,
|
|
41
|
+
redactAttributes,
|
|
42
|
+
redactHeaders,
|
|
43
|
+
redactQueryString,
|
|
44
|
+
redactValue,
|
|
45
|
+
shouldRedactAttribute,
|
|
46
|
+
} from "./sensitive-filter";
|
|
47
|
+
export {
|
|
48
|
+
emitDbQuery,
|
|
49
|
+
emitDispatcherError,
|
|
50
|
+
emitDispatcherHandler,
|
|
51
|
+
emitEventConsumerLag,
|
|
52
|
+
emitEventConsumerPassOutcome,
|
|
53
|
+
emitEventDispatcherListenConnected,
|
|
54
|
+
emitHttpRequest,
|
|
55
|
+
registerStandardMetrics,
|
|
56
|
+
STANDARD_METRIC_DEFS,
|
|
57
|
+
} from "./standard-metrics";
|
|
58
|
+
export type {
|
|
59
|
+
Counter,
|
|
60
|
+
Gauge,
|
|
61
|
+
Histogram,
|
|
62
|
+
Meter,
|
|
63
|
+
MetricDefinition,
|
|
64
|
+
MetricLabels,
|
|
65
|
+
MetricsHandle,
|
|
66
|
+
MetricType,
|
|
67
|
+
ObservabilityOptions,
|
|
68
|
+
ObservabilityProvider,
|
|
69
|
+
SamplingConfig,
|
|
70
|
+
SensitiveFilterConfig,
|
|
71
|
+
SerializedTraceContext,
|
|
72
|
+
Span,
|
|
73
|
+
SpanAttributes,
|
|
74
|
+
SpanAttributeValue,
|
|
75
|
+
SpanKind,
|
|
76
|
+
SpanStatus,
|
|
77
|
+
StartSpanOptions,
|
|
78
|
+
Tracer,
|
|
79
|
+
} from "./types";
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { assertUnreachable } from "../utils";
|
|
2
|
+
import type { MetricType } from "./types";
|
|
3
|
+
|
|
4
|
+
// Boot-time validation of metric names — catches typos and convention
|
|
5
|
+
// violations before any metric is emitted. See observability-naming.md.
|
|
6
|
+
|
|
7
|
+
const SNAKE_CASE = /^[a-z][a-z0-9_]*$/;
|
|
8
|
+
|
|
9
|
+
export function validateMetricName(name: string, type: MetricType): void {
|
|
10
|
+
if (!SNAKE_CASE.test(name)) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
`[Kumiko Observability] Metric "${name}" must be snake_case (a-z, 0-9, _). ` +
|
|
13
|
+
`Type: ${type}.`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
switch (type) {
|
|
18
|
+
case "counter":
|
|
19
|
+
if (!name.endsWith("_total")) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`[Kumiko Observability] Counter "${name}" must end with "_total". ` +
|
|
22
|
+
`Suggested: "${name}_total".`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
// skip: counter suffix validated, nothing more to check
|
|
26
|
+
return;
|
|
27
|
+
|
|
28
|
+
case "histogram":
|
|
29
|
+
if (name.endsWith("_total")) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`[Kumiko Observability] Histogram "${name}" must not end with "_total" ` +
|
|
32
|
+
`— that suffix is reserved for counters.`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
// Histogram must carry a unit suffix (_seconds, _bytes, _eur, ...).
|
|
36
|
+
// Enforce at least one `_<word>` before end to catch naked names.
|
|
37
|
+
if (!/_[a-z]+$/.test(name)) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`[Kumiko Observability] Histogram "${name}" needs a unit suffix ` +
|
|
40
|
+
`(e.g. "${name}_seconds", "${name}_bytes", "${name}_eur").`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
// skip: histogram naming + unit suffix validated
|
|
44
|
+
return;
|
|
45
|
+
|
|
46
|
+
case "gauge":
|
|
47
|
+
if (name.endsWith("_total")) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`[Kumiko Observability] Gauge "${name}" must not end with "_total" ` +
|
|
50
|
+
`— that suffix is reserved for counters.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (name.endsWith("_seconds")) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`[Kumiko Observability] Gauge "${name}" should not end with "_seconds" ` +
|
|
56
|
+
`— duration values are typically histograms.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
// skip: gauge naming validated (no _total, no _seconds)
|
|
60
|
+
return;
|
|
61
|
+
|
|
62
|
+
default:
|
|
63
|
+
assertUnreachable(type, "metric type");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Prefix a short feature-local metric name with the Kumiko + feature prefix.
|
|
68
|
+
// Short name: "created_total". Feature: "orders". Result: "kumiko_orders_created_total".
|
|
69
|
+
export function buildMetricName(featureName: string, shortName: string): string {
|
|
70
|
+
if (!SNAKE_CASE.test(featureName)) {
|
|
71
|
+
throw new Error(`[Kumiko Observability] Feature name "${featureName}" must be snake_case.`);
|
|
72
|
+
}
|
|
73
|
+
return `kumiko_${featureName}_${shortName}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Validate label keys: snake_case, not reserved.
|
|
77
|
+
const RESERVED_LABELS = new Set(["__name__", "le", "quantile"]);
|
|
78
|
+
|
|
79
|
+
export function validateLabelKey(key: string): void {
|
|
80
|
+
if (!SNAKE_CASE.test(key)) {
|
|
81
|
+
throw new Error(`[Kumiko Observability] Label key "${key}" must be snake_case.`);
|
|
82
|
+
}
|
|
83
|
+
if (RESERVED_LABELS.has(key)) {
|
|
84
|
+
throw new Error(`[Kumiko Observability] Label key "${key}" is reserved (Prometheus internal).`);
|
|
85
|
+
}
|
|
86
|
+
}
|