diff --git a/.changeset/good-eels-open.md b/.changeset/good-eels-open.md new file mode 100644 index 0000000000..64f37aaa16 --- /dev/null +++ b/.changeset/good-eels-open.md @@ -0,0 +1,12 @@ +--- +"jazz-tools": patch +"cojson": patch +--- + +Added OpenTelemetry observability support for monitoring subscription performance + +- Instrumented `SubscriptionScope` with metrics (`jazz.subscription.active`, `jazz.subscription.first_load`) and tracing spans +- Added performance tracking for subscription lifecycle events including storage loading, peer fetching, and transaction parsing +- Added new "Perf" tab in Jazz Inspector to visualize subscription load times and metrics +- Added documentation for integrating Jazz with OpenTelemetry-compatible observability backends + diff --git a/examples/music-player/src/2_main.tsx b/examples/music-player/src/2_main.tsx index c03aba2e56..d09e52b4e4 100644 --- a/examples/music-player/src/2_main.tsx +++ b/examples/music-player/src/2_main.tsx @@ -85,7 +85,6 @@ function Main() { return ( - ); } @@ -107,10 +106,8 @@ ReactDOM.createRoot(document.getElementById("root")!).render( authSecretStorageKey="examples/music-player" onAnonymousAccountDiscarded={onAnonymousAccountDiscarded} > - -
- - +
+ , ); diff --git a/homepage/homepage/content/docs/code-snippets/tooling-and-resources/inspector/index.tsx b/homepage/homepage/content/docs/code-snippets/tooling-and-resources/inspector/index.tsx new file mode 100644 index 0000000000..d144767fec --- /dev/null +++ b/homepage/homepage/content/docs/code-snippets/tooling-and-resources/inspector/index.tsx @@ -0,0 +1,38 @@ +// #region WithOpenTelemetry +import React from "react"; +import { jazzMetricReader, JazzInspector } from "jazz-tools/inspector"; +import { JazzReactProvider } from "jazz-tools/react"; +import { + MeterProvider, + PeriodicExportingMetricReader, + ConsoleMetricExporter, +} from "@opentelemetry/sdk-metrics"; +import { metrics } from "@opentelemetry/api"; + +// Include jazzMetricReader alongside your other readers +const meterProvider = new MeterProvider({ + readers: [ + // Your existing metric reader + new PeriodicExportingMetricReader({ + exporter: new ConsoleMetricExporter(), + exportIntervalMillis: 10000, + }), + // Add this to enable the Inspector's Performance tab + jazzMetricReader, + ], +}); + +metrics.setGlobalMeterProvider(meterProvider); + +function App() { + return ( + // @ts-expect-error No sync prop + + {/* Your app components */} + + + ); +} +// #endregion + +export {}; diff --git a/homepage/homepage/content/docs/code-snippets/tooling-and-resources/observability/observability.ts b/homepage/homepage/content/docs/code-snippets/tooling-and-resources/observability/observability.ts new file mode 100644 index 0000000000..9b3f5477d9 --- /dev/null +++ b/homepage/homepage/content/docs/code-snippets/tooling-and-resources/observability/observability.ts @@ -0,0 +1,96 @@ +// #region Metrics +import { unstable_setOpenTelemetryInstrumentationEnabled } from "jazz-tools"; +import { + MeterProvider, + PeriodicExportingMetricReader, + ConsoleMetricExporter, +} from "@opentelemetry/sdk-metrics"; +import { metrics } from "@opentelemetry/api"; + +// Enable instrumentation (required for metrics and tracing) +unstable_setOpenTelemetryInstrumentationEnabled(true); + +// Create a console exporter for development +const metricExporter = new ConsoleMetricExporter(); + +// Set up the meter provider with periodic export +const meterProvider = new MeterProvider({ + readers: [ + new PeriodicExportingMetricReader({ + exporter: metricExporter, + exportIntervalMillis: 10000, // Export every 10 seconds + }), + ], +}); + +// Register the provider globally +metrics.setGlobalMeterProvider(meterProvider); +// #endregion + +// #region Tracing +import { unstable_setOpenTelemetryInstrumentationEnabled } from "jazz-tools"; +import { + BasicTracerProvider, + SimpleSpanProcessor, + ConsoleSpanExporter, +} from "@opentelemetry/sdk-trace-base"; +import { trace } from "@opentelemetry/api"; + +// Enable instrumentation (required for metrics and tracing) +unstable_setOpenTelemetryInstrumentationEnabled(true); + +// Create a console exporter for development +const spanExporter = new ConsoleSpanExporter(); + +// Set up the tracer provider +const tracerProvider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(spanExporter)], +}); + +// Register the provider globally +trace.setGlobalTracerProvider(tracerProvider); +// #endregion + +// #region Production +import { unstable_setOpenTelemetryInstrumentationEnabled } from "jazz-tools"; +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { + MeterProvider as MeterProviderProd, + PeriodicExportingMetricReader as PeriodicExportingMetricReaderProd, +} from "@opentelemetry/sdk-metrics"; +import { + BasicTracerProvider as BasicTracerProviderProd, + BatchSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { metrics as metricsProd, trace as traceProd } from "@opentelemetry/api"; + +// Enable instrumentation (required for metrics and tracing) +unstable_setOpenTelemetryInstrumentationEnabled(true); + +// Configure OTLP exporters pointing to your collector +const otlpMetricExporter = new OTLPMetricExporter({ + url: "https://your-collector.example.com/v1/metrics", +}); + +const otlpTraceExporter = new OTLPTraceExporter({ + url: "https://your-collector.example.com/v1/traces", +}); + +// Set up providers with production exporters +const prodMeterProvider = new MeterProviderProd({ + readers: [ + new PeriodicExportingMetricReaderProd({ + exporter: otlpMetricExporter, + exportIntervalMillis: 60000, // Export every minute + }), + ], +}); + +const prodTracerProvider = new BasicTracerProviderProd({ + spanProcessors: [new BatchSpanProcessor(otlpTraceExporter)], +}); + +metricsProd.setGlobalMeterProvider(prodMeterProvider); +traceProd.setGlobalTracerProvider(prodTracerProvider); +// #endregion diff --git a/homepage/homepage/content/docs/docNavigationItems.js b/homepage/homepage/content/docs/docNavigationItems.js index a3a81e8b7f..09d627b90f 100644 --- a/homepage/homepage/content/docs/docNavigationItems.js +++ b/homepage/homepage/content/docs/docNavigationItems.js @@ -409,6 +409,11 @@ export const docNavigationItems = [ href: "/docs/tooling-and-resources/inspector", done: 100, }, + { + name: "Observability", + href: "/docs/tooling-and-resources/observability", + done: 100, + }, { name: "AI tools (llms.txt)", href: "/docs/tooling-and-resources/ai-tools", diff --git a/homepage/homepage/content/docs/tooling-and-resources/inspector.mdx b/homepage/homepage/content/docs/tooling-and-resources/inspector.mdx index 5a75462cb3..3c3b1c3c16 100644 --- a/homepage/homepage/content/docs/tooling-and-resources/inspector.mdx +++ b/homepage/homepage/content/docs/tooling-and-resources/inspector.mdx @@ -88,3 +88,32 @@ Check out the [music player app](https://github.com/garden-co/jazz/blob/main/exa Check out the [file share app](https://github.com/garden-co/jazz/blob/main/examples/file-share-svelte/src/routes/%2Blayout.svelte) for a full example. + +## Performance Tab + +The Inspector includes a Performance tab that helps you analyze the runtime behavior of your Jazz app. The Performance tab uses OpenTelemetry under the hood to collect metrics. + +Metrics instrumentation is registered automatically if no OpenTelemetry configuration is found. + +### Using with your existing OpenTelemetry setup + +If you already have OpenTelemetry configured in your frontend, you should add `jazzMetricReader` to your MeterProvider's readers array to enable the Performance tab: + + +```tsx index.tsx#WithOpenTelemetry +``` + + +This allows the Inspector's Performance tab to access the metrics while your existing observability setup continues to work normally. + +### What gets tracked + +The Performance tab displays several metrics: + +- **Active subscriptions** — The number of CoValue subscriptions currently active in your app +- **First load time** — How long it takes for a subscription to receive its first data +- **Load breakdown** — Detailed timing for storage reads, peer fetches, and transaction parsing + +These metrics help you identify slow-loading CoValues, understand where time is spent during data loading, and optimize your app's performance. + +The Performance tab also integrates with Chrome DevTools' Performance panel, allowing you to see Jazz-specific timing marks alongside your other performance data. diff --git a/homepage/homepage/content/docs/tooling-and-resources/observability.mdx b/homepage/homepage/content/docs/tooling-and-resources/observability.mdx new file mode 100644 index 0000000000..9b5ce5269c --- /dev/null +++ b/homepage/homepage/content/docs/tooling-and-resources/observability.mdx @@ -0,0 +1,154 @@ +import { CodeGroup } from '@/components/forMdx' + +export const metadata = { + description: "Unstable API: Learn how to integrate Jazz with OpenTelemetry for metrics and tracing." +}; + +# Observability + +
+ ⚠️ **Unstable API**: The observability API is experimental and subject to breaking changes in future releases. Use with caution in production environments. +
+ +Jazz exposes metrics and spans using the [OpenTelemetry](https://opentelemetry.io/) protocol. This allows you to integrate Jazz's internal performance data with your existing observability stack, whether that's a console logger for development or a full observability platform in production. + +## Enabling instrumentation + +OpenTelemetry instrumentation is **opt-in** and disabled by default for performance reasons. To enable it, call `unstable_setOpenTelemetryInstrumentationEnabled` before setting up your OpenTelemetry providers: + +```ts +import { unstable_setOpenTelemetryInstrumentationEnabled } from "jazz-tools"; + +// Enable instrumentation - do this before setting up your OpenTelemetry providers +unstable_setOpenTelemetryInstrumentationEnabled(true); +``` + +
+ 💡 **Note**: You only need to enable instrumentation when you want to collect metrics or traces. If you're not using OpenTelemetry, leave it disabled to avoid any performance overhead. +
+ +## Available metrics + +Jazz tracks the following metrics: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricTypeDescription
jazz.subscription.activeUpDownCounterNumber of active CoValue subscriptions
jazz.subscription.first_loadHistogramTime for a subscription to receive its first data (ms)
jazz.peersUpDownCounterNumber of connected peers
jazz.transactions.sizeHistogramSize of transactions (bytes)
jazz.usage.ingressCounterTotal bytes received from peers
jazz.usage.egressCounterTotal bytes sent to peers
+ +## Available spans + +Jazz creates spans to track the subscription lifecycle: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SpanDescription
jazz.subscriptionParent span for the entire subscription lifecycle
jazz.subscription.first_loadSpan for the initial data load
jazz.subscription.first_load.from_storageTime spent loading from local storage
jazz.subscription.first_load.load_from_peerTime spent fetching from a peer
jazz.subscription.first_load.transaction_parsingTime spent parsing transactions
+ +## Setting up metrics + +To collect metrics, you need to: +1. Enable instrumentation using `unstable_setOpenTelemetryInstrumentationEnabled(true)` +2. Configure an OpenTelemetry `MeterProvider` with a reader that exports the data + +Here's a simple example that logs metrics to the console: + + +```ts tooling-and-resources/observability/observability.ts#Metrics +``` + + +**Tip:** If you're using the [Jazz Inspector](/docs/tooling-and-resources/inspector#performance-tab) in your app, add `jazzMetricReader` from `jazz-tools/inspector` to your MeterProvider's readers array to enable the Performance tab. + +## Setting up tracing + +To collect spans, you need to: +1. Enable instrumentation using `unstable_setOpenTelemetryInstrumentationEnabled(true)` +2. Configure a `TracerProvider` with a span processor + +Here's an example that logs spans to the console: + + +```ts tooling-and-resources/observability/observability.ts#Tracing +``` + + +Check the [OpenTelemetry JS documentation](https://opentelemetry.io/docs/languages/js/) for more details on available exporters and configuration options. + +## Production setup + +In production, you'll typically want to use OTLP exporters to send data to your observability platform (like Jaeger, Prometheus, or a managed service). Here's a complete example: + + +```ts tooling-and-resources/observability/observability.ts#Production +``` + + +This example uses HTTP-based OTLP exporters, but you can also use gRPC-based exporters depending on your infrastructure. The key differences from development setup are: + +- Using `BatchSpanProcessor` for more efficient span batching in production +- Longer export intervals (60 seconds vs 10 seconds) to reduce network overhead +- OTLP exporters that send data to your collector or observability platform diff --git a/homepage/homepage/package.json b/homepage/homepage/package.json index d8880b8819..1e098367ee 100644 --- a/homepage/homepage/package.json +++ b/homepage/homepage/package.json @@ -67,6 +67,10 @@ "@biomejs/biome": "catalog:default", "@evilmartians/harmony": "^1.4.0", "@next/mdx": "^15.3.3", + "@opentelemetry/exporter-metrics-otlp-http": "^0.208.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.208.0", + "@opentelemetry/sdk-metrics": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.2.0", "@playwright/test": "^1.52.0", "@tailwindcss/postcss": "^4.1.16", "@tailwindcss/typography": "^0.5.16", @@ -83,8 +87,8 @@ "postcss": "^8", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.7.1", - "tailwindcss": "^4.1.16", "svelte-check": "^4.3.3", + "tailwindcss": "^4.1.16", "turbo": "^2.3.1", "typescript": "catalog:default", "unified": "^11.0.5", diff --git a/homepage/homepage/tsconfig.snippets.json b/homepage/homepage/tsconfig.snippets.json index 768a4e1393..4633a49934 100644 --- a/homepage/homepage/tsconfig.snippets.json +++ b/homepage/homepage/tsconfig.snippets.json @@ -49,6 +49,10 @@ ], "@react-navigation/*": [ "./content/docs/code-snippets/project-setup/*" + ], + "@opentelemetry/*": [ + "./content/docs/code-snippets/tooling-and-resources/inspector/*", + "./content/docs/code-snippets/tooling-and-resources/observability/*" ] } }, diff --git a/homepage/pnpm-lock.yaml b/homepage/pnpm-lock.yaml index 6a4b8987e6..69364c010c 100644 --- a/homepage/pnpm-lock.yaml +++ b/homepage/pnpm-lock.yaml @@ -59,10 +59,10 @@ importers: version: 0.436.0(react@19.1.0) next: specifier: 15.5.8 - version: 15.5.8(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 15.5.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next-themes: specifier: ^0.2.1 - version: 0.2.1(next@15.5.8(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 0.2.1(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) postcss: specifier: ^8 version: 8.5.6 @@ -132,10 +132,10 @@ importers: version: 2.0.13 '@vercel/analytics': specifier: ^1.3.1 - version: 1.5.0(next@15.5.8(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.41.1) + version: 1.5.0(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.41.1) '@vercel/speed-insights': specifier: ^1.0.12 - version: 1.2.0(next@15.5.8(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.41.1) + version: 1.2.0(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.41.1) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -156,10 +156,10 @@ importers: version: 3.0.0 next: specifier: 15.5.8 - version: 15.5.8(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 15.5.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next-themes: specifier: ^0.2.1 - version: 0.2.1(next@15.5.8(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 0.2.1(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: specifier: catalog:react version: 19.1.0 @@ -256,10 +256,10 @@ importers: version: 3.1.5 '@vercel/analytics': specifier: ^1.3.1 - version: 1.5.0(next@15.5.8(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.41.1) + version: 1.5.0(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.41.1) '@vercel/speed-insights': specifier: ^1.0.12 - version: 1.2.0(next@15.5.8(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.41.1) + version: 1.2.0(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.41.1) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -301,10 +301,10 @@ importers: version: 3.0.0 next: specifier: 15.5.8 - version: 15.5.8(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 15.5.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next-themes: specifier: ^0.2.1 - version: 0.2.1(next@15.5.8(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 0.2.1(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) node-html-markdown: specifier: ^1.3.0 version: 1.3.0 @@ -342,6 +342,18 @@ importers: '@next/mdx': specifier: ^15.3.3 version: 15.5.2(@mdx-js/loader@2.3.0(webpack@5.101.3(esbuild@0.25.9)))(@mdx-js/react@2.3.0(react@19.1.0)) + '@opentelemetry/exporter-metrics-otlp-http': + specifier: ^0.208.0 + version: 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': + specifier: ^0.208.0 + version: 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': + specifier: ^2.2.0 + version: 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': + specifier: ^2.2.0 + version: 2.2.0(@opentelemetry/api@1.9.0) '@playwright/test': specifier: ^1.52.0 version: 1.55.0 @@ -1200,6 +1212,72 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api-logs@0.208.0': + resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.2.0': + resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-metrics-otlp-http@0.208.0': + resolution: {integrity: sha512-QZ3TrI90Y0i1ezWQdvreryjY0a5TK4J9gyDLIyhLBwV+EQUvyp5wR7TFPKCAexD4TDSWM0t3ulQDbYYjVtzTyA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.208.0': + resolution: {integrity: sha512-jbzDw1q+BkwKFq9yxhjAJ9rjKldbt5AgIy1gmEIJjEV/WRxQ3B6HcLVkwbjJ3RcMif86BDNKR846KJ0tY0aOJA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.208.0': + resolution: {integrity: sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.208.0': + resolution: {integrity: sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.2.0': + resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.208.0': + resolution: {integrity: sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.2.0': + resolution: {integrity: sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.2.0': + resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.38.0': + resolution: {integrity: sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==} + engines: {node: '>=14'} + '@pagefind/darwin-arm64@1.3.0': resolution: {integrity: sha512-365BEGl6ChOsauRjyVpBjXybflXAOvoMROw3TucAROHIcdBvXk9/2AmEvGFU0r75+vdQI4LJdJdpH4Y6Yqaj4A==} cpu: [arm64] @@ -1252,6 +1330,36 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -4104,6 +4212,9 @@ packages: lodash.throttle@4.1.1: resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -4537,6 +4648,7 @@ packages: next@15.5.8: resolution: {integrity: sha512-Tma2R50eiM7Fx6fbDeHiThq7sPgl06mBr76j6Ga0lMFGrmaLitFsy31kykgb8Z++DR2uIEKi2RZ0iyjIwFd15Q==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -4844,6 +4956,10 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + pvtsutils@1.3.6: resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} @@ -6586,6 +6702,80 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@opentelemetry/api-logs@0.208.0': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.38.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-trace-otlp-http@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-exporter-base@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) + protobufjs: 7.5.4 + + '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + + '@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + + '@opentelemetry/semantic-conventions@1.38.0': {} + '@pagefind/darwin-arm64@1.3.0': optional: true @@ -6643,6 +6833,29 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -9036,15 +9249,15 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vercel/analytics@1.5.0(next@15.5.8(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.41.1)': + '@vercel/analytics@1.5.0(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.41.1)': optionalDependencies: - next: 15.5.8(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.5.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 svelte: 5.41.1 - '@vercel/speed-insights@1.2.0(next@15.5.8(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.41.1)': + '@vercel/speed-insights@1.2.0(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(svelte@5.41.1)': optionalDependencies: - next: 15.5.8(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.5.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 svelte: 5.41.1 @@ -10424,6 +10637,8 @@ snapshots: lodash.throttle@4.1.1: {} + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -11350,13 +11565,13 @@ snapshots: neo-async@2.6.2: {} - next-themes@0.2.1(next@15.5.8(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next-themes@0.2.1(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - next: 15.5.8(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.5.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - next@15.5.8(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next@15.5.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 15.5.8 '@swc/helpers': 0.5.15 @@ -11374,6 +11589,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.7 '@next/swc-win32-arm64-msvc': 15.5.7 '@next/swc-win32-x64-msvc': 15.5.7 + '@opentelemetry/api': 1.9.0 '@playwright/test': 1.55.0 sharp: 0.34.5 transitivePeerDependencies: @@ -11602,6 +11818,21 @@ snapshots: property-information@7.1.0: {} + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 20.19.13 + long: 5.3.2 + pvtsutils@1.3.6: dependencies: tslib: 2.8.1 diff --git a/packages/cojson/src/coValueCore/coValueCore.ts b/packages/cojson/src/coValueCore/coValueCore.ts index 7ec0241478..3821d3334a 100644 --- a/packages/cojson/src/coValueCore/coValueCore.ts +++ b/packages/cojson/src/coValueCore/coValueCore.ts @@ -1,4 +1,4 @@ -import { UpDownCounter, ValueType, metrics } from "@opentelemetry/api"; +import { type UpDownCounter, ValueType, metrics } from "@opentelemetry/api"; import type { PeerState } from "../PeerState.js"; import type { RawCoValue } from "../coValue.js"; import type { ControlledAccountOrAgent } from "../coValues/account.js"; @@ -50,6 +50,7 @@ import { KnownStateSessions, } from "../knownState.js"; import { safeParseJSON } from "../jsonStringify.js"; +import { trackPerformanceMark } from "../perf-utils.js"; export function idforHeader( header: CoValueHeader, @@ -1085,6 +1086,7 @@ export class CoValueCore { if (!this.isAvailable()) { return; } + this.loadVerifiedTransactionsFromLogs(); this.determineValidTransactions(); @@ -1477,12 +1479,15 @@ export class CoValueCore { } this.markPending("storage"); + + trackPerformanceMark("loadFromStorageStart", this.id); node.storage.load( this.id, (data) => { node.syncManager.handleNewContent(data, "storage"); }, (found) => { + trackPerformanceMark("loadFromStorageEnd", this.id, { found }); done?.(found); if (!found) { @@ -1540,6 +1545,10 @@ export class CoValueCore { ? undefined : peer.addCloseListener(markNotFound); + trackPerformanceMark("loadFromPeerStart", this.id, { + peerID: peer.id, + }); + this.subscribe((state, unsubscribe) => { const peerState = state.getLoadingStateForPeer(peer.id); if ( @@ -1548,6 +1557,9 @@ export class CoValueCore { peerState === "errored" || peerState === "unavailable" ) { + trackPerformanceMark("loadFromPeerEnd", this.id, { + peerID: peer.id, + }); unsubscribe(); removeCloseListener?.(); clearTimeout(timeout); diff --git a/packages/cojson/src/exports.ts b/packages/cojson/src/exports.ts index 99e6a702e4..37f4a0508b 100644 --- a/packages/cojson/src/exports.ts +++ b/packages/cojson/src/exports.ts @@ -92,6 +92,7 @@ import { CO_VALUE_PRIORITY, getPriorityFromHeader } from "./priority.js"; import { getDependedOnCoValues } from "./storage/syncUtils.js"; import { canBeBranched } from "./coValueCore/branching.js"; import type { PeerState } from "./PeerState.js"; +import { performanceMarks } from "./perf-utils.js"; type Value = JsonValue | AnyRawCoValue; @@ -181,6 +182,8 @@ export { isAccountRole, }; +export const perf = { performanceMarks }; + export type { Value, DisconnectedError, diff --git a/packages/cojson/src/perf-utils.ts b/packages/cojson/src/perf-utils.ts new file mode 100644 index 0000000000..c17d585185 --- /dev/null +++ b/packages/cojson/src/perf-utils.ts @@ -0,0 +1,25 @@ +import { RawCoID } from "./exports"; + +export const performanceMarks = { + loadFromStorageStart: "cojson.load_from_storage.start", + loadFromStorageEnd: "cojson.load_from_storage.end", + loadFromPeerStart: "cojson.load_from_peer.start", + loadFromPeerEnd: "cojson.load_from_peer.end", +} as const; + +const performanceMarkAvailable = + "mark" in performance && "getEntriesByName" in performance; + +export function trackPerformanceMark( + mark: keyof typeof performanceMarks, + coId: RawCoID, + detail?: Record, +) { + if (!performanceMarkAvailable) { + return; + } + + performance.mark(performanceMarks[mark] + "." + coId, { + detail, + }); +} diff --git a/packages/jazz-tools/src/inspector/in-app.tsx b/packages/jazz-tools/src/inspector/in-app.tsx index 09844949b6..6005261113 100644 --- a/packages/jazz-tools/src/inspector/in-app.tsx +++ b/packages/jazz-tools/src/inspector/in-app.tsx @@ -1,5 +1,6 @@ import { CoID, LocalNode, RawAccount } from "cojson"; import { styled } from "goober"; +import { useEffect, useRef } from "react"; import { PageStack } from "./viewer/page-stack.js"; import { GlobalStyles } from "./ui/global-styles.js"; import { InspectorButton, type Position } from "./viewer/inspector-button.js"; @@ -8,6 +9,8 @@ import { NodeProvider } from "./contexts/node.js"; import { InMemoryRouterProvider } from "./router/in-memory-router.js"; import { Header } from "./viewer/header.js"; +let instanceCount = 0; + export function InspectorInApp({ position = "right", localNode, @@ -18,6 +21,22 @@ export function InspectorInApp({ accountId?: CoID; }) { const [open, setOpen] = useOpenInspector(); + const hasWarnedRef = useRef(false); + + useEffect(() => { + instanceCount++; + + if (instanceCount > 1 && !hasWarnedRef.current) { + console.error( + `[InspectorInApp] Multiple instances detected (${instanceCount}). Only one InspectorInApp should be rendered at a time.`, + ); + hasWarnedRef.current = true; + } + + return () => { + instanceCount--; + }; + }, []); if (!open) { return ( @@ -33,6 +52,7 @@ export function InspectorInApp({ showDeleteLocalData={true} showClose={true} onClose={() => setOpen(false)} + showPerformance={true} /> diff --git a/packages/jazz-tools/src/inspector/index.tsx b/packages/jazz-tools/src/inspector/index.tsx index d32557c0dc..3d3725684f 100644 --- a/packages/jazz-tools/src/inspector/index.tsx +++ b/packages/jazz-tools/src/inspector/index.tsx @@ -4,6 +4,9 @@ import { useJazzContext } from "jazz-tools/react-core"; import { Account } from "jazz-tools"; import { InspectorInApp } from "./in-app.js"; import { Position } from "./viewer/inspector-button.js"; +import { recordMetrics } from "./utils/instrumentation"; + +export { jazzMetricReader } from "./utils/instrumentation"; export function JazzInspector({ position = "right" }: { position?: Position }) { const context = useJazzContext(); @@ -29,3 +32,4 @@ export function JazzInspector({ position = "right" }: { position?: Position }) { } setup(React.createElement); +recordMetrics(); diff --git a/packages/jazz-tools/src/inspector/utils/instrumentation.ts b/packages/jazz-tools/src/inspector/utils/instrumentation.ts new file mode 100644 index 0000000000..e57376e702 --- /dev/null +++ b/packages/jazz-tools/src/inspector/utils/instrumentation.ts @@ -0,0 +1,93 @@ +import { MeterProvider } from "@opentelemetry/sdk-metrics"; +import { resourceFromAttributes } from "@opentelemetry/resources"; +import { metrics } from "@opentelemetry/api"; +import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; +import { + MetricReader, + InMemoryMetricExporter, + AggregationTemporality, +} from "@opentelemetry/sdk-metrics"; + +export class JazzOTelMetricReader extends MetricReader { + private initialized = false; + private exporter = new InMemoryMetricExporter( + AggregationTemporality.CUMULATIVE, + ); + + protected onInitialized(): void { + this.initialized = true; + } + + protected onShutdown(): Promise { + throw new Error("Method not implemented."); + } + protected onForceFlush(): Promise { + throw new Error("Method not implemented."); + } + + async getMetricDataPoints(scope: string, name: string) { + await this.collectAndExport(); + + const metric = this.exporter + .getMetrics()[0] + ?.scopeMetrics?.find((sm) => sm.scope.name === scope) + ?.metrics.find((m) => m.descriptor.name === name); + + this.exporter.reset(); + + const dp = metric?.dataPoints; + + return dp; + } + + async collectMetrics() { + if (!this.initialized) { + throw new Error("JazzOTelMetricReader not initialized."); + } + + await this.collectAndExport(); + const metrics = this.exporter.getMetrics(); + this.exporter.reset(); + return metrics; + } + + async collectAndExport(): Promise { + const result = await this.collect(); + await new Promise((resolve, reject) => { + this.exporter.export(result.resourceMetrics, (result) => { + if (result.error != null) { + reject(result.error); + } else { + resolve(); + } + }); + }); + } +} + +export const jazzMetricReader = new JazzOTelMetricReader(); + +export function recordMetrics() { + const globalMeterHasBeenSet = !metrics + .getMeterProvider() + .constructor.name.startsWith("Noop"); + + if (globalMeterHasBeenSet) { + return; + } + + const meterProvider = new MeterProvider({ + resource: resourceFromAttributes({ + [ATTR_SERVICE_NAME]: "jazz-tools", + }), + readers: [jazzMetricReader], + }); + + // Register the global meter provider + const res = metrics.setGlobalMeterProvider(meterProvider); + + if (res !== true) { + console.error("Failed to set OTel meter provider"); + return; + } +} diff --git a/packages/jazz-tools/src/inspector/utils/performances.ts b/packages/jazz-tools/src/inspector/utils/performances.ts new file mode 100644 index 0000000000..f83fe606c2 --- /dev/null +++ b/packages/jazz-tools/src/inspector/utils/performances.ts @@ -0,0 +1,267 @@ +import type { + DataPoint, + Histogram, + ResourceMetrics, + ScopeMetrics, +} from "@opentelemetry/sdk-metrics"; + +type ActiveSubscription = { + id: string; + sources: string[]; + count: number; + resolve?: string; +}; + +export type LoadTimeMetric = { + id: string; + source_id?: string; + parent_id?: string; + parent_key?: string; + resolve?: string; + loadTime: number; + loadFrom: "storage" | "network" | "memory"; + startTime: number; +}; + +export type OTelMetrics = { + transport: { + ingress: number; + egress: number; + }; + cojson: { + available: number; + loading: number; + unknown: number; + unavailable: number; + }; + activeSubscriptions: ActiveSubscription[]; +}; + +export type PerfMetrics = { + transport: { + ingress: number; + egress: number; + }; + cojson: { + available: number; + loading: number; + unknown: number; + unavailable: number; + }; + tools: { + activeSubscriptions: ActiveSubscription[]; + loadTimes: LoadTimeMetric[]; + }; +}; + +export function getActiveSubscriptions( + metrics: ScopeMetrics[], +): ActiveSubscription[] { + const scope = "jazz-tools"; + const name = "jazz.subscription.active"; + + const dps = metrics + .find((sm) => sm.scope.name === scope) + ?.metrics.find((m) => m.descriptor.name === name)?.dataPoints as + | DataPoint[] + | undefined; + + if (!dps) { + return []; + } + + const subs = new Map(); + + for (const dp of dps) { + if (dp.value === 0) { + continue; + } + + const id = dp.attributes.id as string; + const source_id = dp.attributes.source_id as string | undefined; + const value = dp.value; + + // In the inspector, we only want to count the top-level subscriptions + if (source_id !== undefined) { + continue; + } + + const key = `${id}-${dp.attributes.resolve}`; + + let sub = subs.get(key); + if (!sub) { + sub = { + id, + sources: [], + count: 0, + resolve: dp.attributes.resolve as string, + }; + subs.set(key, sub); + } + + if (source_id) { + sub.sources.push(source_id); + } + + sub.count += value; + } + + return Array.from(subs.values()); +} + +export function exportOTelMetrics( + resourceMetrics: ResourceMetrics[], +): OTelMetrics | null { + const scopedMetrics = resourceMetrics.at(0)?.scopeMetrics; + + if (!scopedMetrics) { + return null; + } + + const ingress = getSumOfCounterMetric( + scopedMetrics, + "cojson-transport-ws", + "jazz.usage.ingress", + ); + const egress = getSumOfCounterMetric( + scopedMetrics, + "cojson-transport-ws", + "jazz.usage.egress", + ); + const availableCoValues = getSumOfCounterMetric( + scopedMetrics, + "cojson", + "jazz.covalues.loaded", + { + state: "available", + }, + ); + const loadingCoValues = getSumOfCounterMetric( + scopedMetrics, + "cojson", + "jazz.covalues.loaded", + { + state: "loading", + }, + ); + + const unknownCoValues = getSumOfCounterMetric( + scopedMetrics, + "cojson", + "jazz.covalues.loaded", + { + state: "unknown", + }, + ); + + const unavailableCoValues = getSumOfCounterMetric( + scopedMetrics, + "cojson", + "jazz.covalues.loaded", + { + state: "unavailable", + }, + ); + + return { + transport: { + ingress, + egress, + }, + cojson: { + available: availableCoValues, + loading: loadingCoValues, + unknown: unknownCoValues, + unavailable: unavailableCoValues, + }, + activeSubscriptions: getActiveSubscriptions(scopedMetrics), + }; +} + +export function exportMetrics( + resourceMetrics: ResourceMetrics[], +): PerfMetrics | null { + const otelMetrics = exportOTelMetrics(resourceMetrics); + + if (!otelMetrics) { + return null; + } + + return { + transport: otelMetrics.transport, + cojson: otelMetrics.cojson, + tools: { + loadTimes: getLoadTimes(), + activeSubscriptions: otelMetrics.activeSubscriptions, + }, + }; +} + +function getSumOfCounterMetric( + metrics: ScopeMetrics[], + scope: string, + name: string, + attributes?: Record, +) { + const dp = metrics + .find((sm) => sm.scope.name === scope) + ?.metrics.find((m) => m.descriptor.name === name)?.dataPoints; + + if (!dp) { + return 0; + } + + return dp.reduce((acc, dp) => { + if (typeof dp.value !== "number") { + throw new Error(`Metric ${name} has a value that is not a number`); + } + + // if attributes is defined, and the attributes do not match, skip this counter + if ( + attributes && + !Object.keys(attributes).every( + (key) => dp.attributes[key] === attributes[key], + ) + ) { + return acc; + } + + return acc + dp.value; + }, 0); +} + +export function getLoadTimes(): LoadTimeMetric[] { + if ( + typeof performance === "undefined" || + !("getEntriesByType" in performance) + ) { + return []; + } + + const measures = performance.getEntriesByType( + "measure", + ) as PerformanceMeasure[]; + const loadMeasures = measures.filter((measure) => + measure.name.startsWith("jazz.subscription.first_load."), + ); + + return loadMeasures + .filter((measure) => !measure.detail?.parent_id) + .map((measure) => { + const detail = measure.detail || {}; + return { + id: detail.id as string, + source_id: detail.source_id as string | undefined, + parent_id: detail.parent_id, + parent_key: detail.parent_key, + resolve: JSON.stringify(detail.resolve as any), + loadTime: measure.duration, + loadFrom: detail.loadFromStorage + ? ("storage" as const) + : detail.loadFromPeer + ? ("network" as const) + : ("memory" as const), + startTime: performance.timeOrigin + measure.startTime, + }; + }); +} diff --git a/packages/jazz-tools/src/inspector/viewer/header.tsx b/packages/jazz-tools/src/inspector/viewer/header.tsx index 8ccb4590d1..8b1b171891 100644 --- a/packages/jazz-tools/src/inspector/viewer/header.tsx +++ b/packages/jazz-tools/src/inspector/viewer/header.tsx @@ -10,15 +10,17 @@ import { useRouter } from "../router/context.js"; export function Header({ showDeleteLocalData = false, showClose = false, + showPerformance = false, onClose, children, }: PropsWithChildren<{ showDeleteLocalData?: boolean; showClose?: boolean; + showPerformance?: boolean; onClose?: () => void; }>) { const [coValueId, setCoValueId] = useState | "">(""); - const { path, setPage } = useRouter(); + const { path, setPage, addPages } = useRouter(); const handleCoValueIdSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -44,6 +46,16 @@ export function Header({ )} {children} + {showPerformance && ( + + )} {showDeleteLocalData && } {showClose && (