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:
+
+
+
+
+ Metric
+ Type
+ Description
+
+
+
+
+ jazz.subscription.active
+ UpDownCounter
+ Number of active CoValue subscriptions
+
+
+ jazz.subscription.first_load
+ Histogram
+ Time for a subscription to receive its first data (ms)
+
+
+ jazz.peers
+ UpDownCounter
+ Number of connected peers
+
+
+ jazz.transactions.size
+ Histogram
+ Size of transactions (bytes)
+
+
+ jazz.usage.ingress
+ Counter
+ Total bytes received from peers
+
+
+ jazz.usage.egress
+ Counter
+ Total bytes sent to peers
+
+
+
+
+## Available spans
+
+Jazz creates spans to track the subscription lifecycle:
+
+
+
+
+ Span
+ Description
+
+
+
+
+ jazz.subscription
+ Parent span for the entire subscription lifecycle
+
+
+ jazz.subscription.first_load
+ Span for the initial data load
+
+
+ jazz.subscription.first_load.from_storage
+ Time spent loading from local storage
+
+
+ jazz.subscription.first_load.load_from_peer
+ Time spent fetching from a peer
+
+
+ jazz.subscription.first_load.transaction_parsing
+ Time 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 && (
+
+ addPages([{ coId: "performance" as any, name: "Performance" }])
+ }
+ >
+ Perf
+
+ )}
{showDeleteLocalData && }
{showClose && (
diff --git a/packages/jazz-tools/src/inspector/viewer/page-stack.tsx b/packages/jazz-tools/src/inspector/viewer/page-stack.tsx
index 0602d3b854..f8935c069b 100644
--- a/packages/jazz-tools/src/inspector/viewer/page-stack.tsx
+++ b/packages/jazz-tools/src/inspector/viewer/page-stack.tsx
@@ -5,6 +5,7 @@ import { ErrorBoundary } from "../ui/error-boundary.js";
import { useRouter } from "../router/context.js";
import { useNode } from "../contexts/node.js";
import { HomePage } from "../pages/home.js";
+import Perf from "./perf.js";
const PageStackContainer = styled("article")`
position: relative;
@@ -30,6 +31,14 @@ export function PageStack({ homePage }: PageStackProps) {
return {homePage ?? } ;
}
+ if ((page?.coId as any) === "performance") {
+ return (
+
+
+
+ );
+ }
+
return (
<>
diff --git a/packages/jazz-tools/src/inspector/viewer/perf.tsx b/packages/jazz-tools/src/inspector/viewer/perf.tsx
new file mode 100644
index 0000000000..e27f99adc9
--- /dev/null
+++ b/packages/jazz-tools/src/inspector/viewer/perf.tsx
@@ -0,0 +1,373 @@
+import { useEffect, useState } from "react";
+import { styled } from "goober";
+import { CoID, RawCoValue } from "cojson";
+import { Accordion } from "../ui/accordion";
+import { DataTable, type ColumnDef } from "../ui/data-table";
+import { Heading } from "../ui/heading";
+import { Text } from "../ui/text";
+import { useRouter } from "../router";
+import { jazzMetricReader } from "../utils/instrumentation";
+import {
+ exportOTelMetrics,
+ getLoadTimes,
+ type OTelMetrics,
+ type LoadTimeMetric,
+} from "../utils/performances";
+
+export default function Perf() {
+ const [otelError, setOtelError] = useState(null);
+ const [otelMetrics, setOtelMetrics] = useState(null);
+ const [loadTimes, setLoadTimes] = useState([]);
+ const [loadTimesResetTimestamp, setLoadTimesResetTimestamp] = useState<
+ number | null
+ >(null);
+ const { addPages } = useRouter();
+
+ useEffect(() => {
+ async function fetchMetrics() {
+ try {
+ const metrics = await jazzMetricReader.collectMetrics();
+ setOtelMetrics(exportOTelMetrics(metrics));
+ setOtelError(null);
+ } catch (error) {
+ setOtelError(error as Error);
+ }
+
+ setLoadTimes(getLoadTimes());
+ }
+
+ const interval = setInterval(fetchMetrics, 1_000);
+ fetchMetrics();
+ return () => clearInterval(interval);
+ }, []);
+
+ const hasAnyData = otelMetrics !== null || loadTimes.length > 0;
+
+ if (!hasAnyData && otelError) {
+ return (
+
+
+ Error fetching metrics: {otelError.message}. Check the{" "}
+
+ docs
+
+ .
+
+
+ );
+ }
+
+ if (!hasAnyData) {
+ return (
+
+ Loading metrics...
+
+ );
+ }
+
+ const loadTimeColumns: ColumnDef[] = [
+ {
+ id: "loadTime",
+ header: "Load Time",
+ accessor: (row) => formatTime(row.loadTime),
+ sortable: true,
+ sortFn: (a, b) => a.loadTime - b.loadTime,
+ },
+ {
+ id: "id",
+ header: "CoValue ID",
+ accessor: (row) => (
+
+ addPages([{ coId: row.id as CoID, name: row.id }])
+ }
+ >
+
+ {row.id}
+
+
+ ),
+ sortable: true,
+ },
+ // Path is visible only for nested subscriptions, now ignored
+ // {
+ // id: "source_id",
+ // header: "Path",
+ // accessor: (row) => {
+ // if(row.source_id === row.id || row.source_id === undefined) {
+ // return - ;
+ // }
+
+ // const elements: React.ReactNode[] = [];
+
+ // elements.push( );
+ // const keys = (row.parent_key ?? '').split(".");
+
+ // keys.forEach((key, index) => {
+ // if(index === keys.length - 2) {
+ // elements.push(<>. >)
+ // } else {
+ // elements.push(`.${key}`)
+ // }
+ // })
+
+ // return {elements} ;
+ // },
+ // sortable: true,
+ // },
+ {
+ id: "resolve",
+ header: "Resolve",
+ accessor: (row) =>
+ row.resolve ? (
+ {row.resolve}
+ ) : (
+
+ —
+
+ ),
+ sortable: false,
+ },
+ {
+ id: "loadFrom",
+ header: "Loaded from",
+ accessor: (row) => row.loadFrom,
+ sortable: false,
+ },
+ ];
+
+ const subscriptionColumns: ColumnDef<
+ OTelMetrics["activeSubscriptions"][number]
+ >[] = [
+ {
+ id: "id",
+ header: "CoValue ID",
+ accessor: (row) => (
+
+ addPages([{ coId: row.id as CoID, name: row.id }])
+ }
+ >
+
+ {row.id}
+
+
+ ),
+ sortable: true,
+ },
+ {
+ id: "resolve",
+ header: "Resolve",
+ accessor: (row) => (
+ {row.resolve === "true" ? "" : row.resolve}
+ ),
+ sortable: false,
+ },
+ {
+ id: "count",
+ header: "Count",
+ accessor: (row) => {row.count} ,
+ sortable: true,
+ sortFn: (a, b) => a.count - b.count,
+ },
+ ];
+
+ const filteredLoadTimes = loadTimesResetTimestamp
+ ? loadTimes.filter((lt) => lt.startTime >= loadTimesResetTimestamp)
+ : loadTimes;
+
+ const slowLoadTimesCount = filteredLoadTimes.filter(
+ (lt) => lt.loadTime > 200,
+ ).length;
+
+ const activeSubscriptions = otelMetrics?.activeSubscriptions ?? [];
+
+ return (
+
+ {otelError && (
+
+ OTel metrics error: {otelError.message}
+
+ )}
+
+
+
+ CoValues by State
+ {otelMetrics ? (
+
+ {otelMetrics.cojson.available} available
+ {otelMetrics.cojson.loading} loading
+ {otelMetrics.cojson.unknown} unknown
+ {otelMetrics.cojson.unavailable} unavailable
+
+ ) : (
+
+ Not available
+
+ )}
+
+
+
+ Transport Metrics
+ {otelMetrics ? (
+
+
+ In: {" "}
+ {formatBytes(otelMetrics.transport.ingress)}
+
+
+ Out: {" "}
+ {formatBytes(otelMetrics.transport.egress)}
+
+
+ ) : (
+
+ Not available
+
+ )}
+
+
+
+ {!!otelError || (
+
+ {activeSubscriptions.length === 0 ? (
+ No active subscriptions
+ ) : (
+ `${row.id}-${index}`}
+ emptyMessage="No active subscriptions"
+ />
+ )}
+
+ )}
+
+ 0 ? `(${slowLoadTimesCount} slow)` : ""}`}
+ storageKey="perf-load-times"
+ >
+
+ setLoadTimesResetTimestamp(Date.now())}
+ title="Reset load times"
+ >
+ Reset
+
+
+ {filteredLoadTimes.length === 0 ? (
+ No load time data available
+ ) : (
+ `${row.id}-${index}`}
+ emptyMessage="No load time data available"
+ />
+ )}
+
+
+ );
+}
+
+function formatBytes(bytes: number): string {
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
+}
+
+function formatTime(ms: number): string {
+ if (ms < 1) return `${(ms * 1000).toFixed(2)} μs`;
+ if (ms < 1000) return `${ms.toFixed(2)} ms`;
+ return `${(ms / 1000).toFixed(2)} s`;
+}
+
+const DashboardContainer = styled("div")`
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+ padding: 1rem 0;
+`;
+
+const SectionHeading = styled(Heading)`
+ font-size: 1rem;
+ text-align: left;
+`;
+
+const MetricsSummaryRow = styled("div")`
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1.5rem;
+ justify-content: space-between;
+`;
+
+const MetricsSummaryColumn = styled("div")`
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 1rem;
+`;
+
+const MetricsSummaryItems = styled("div")`
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+
+ @media (min-width: 724px) {
+ flex-direction: row;
+ gap: 1rem;
+ }
+`;
+
+const ClickableId = styled("span")`
+ cursor: pointer;
+ color: var(--j-link-color);
+ text-decoration: underline;
+
+ &:hover {
+ opacity: 0.8;
+ }
+`;
+
+const ErrorBanner = styled("div")`
+ padding: 0.75rem 1rem;
+ background-color: var(--j-error-bg, rgba(239, 68, 68, 0.1));
+ border: 1px solid var(--j-error-border, rgba(239, 68, 68, 0.3));
+ border-radius: 4px;
+ color: var(--j-error-color, #ef4444);
+`;
+
+const AccordionToolbar = styled("div")`
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+`;
+
+const ResetButton = styled("button")`
+ padding: 0.25rem 0.5rem;
+ font-size: 0.75rem;
+ background-color: var(--j-button-bg, rgba(128, 128, 128, 0.2));
+ border: 1px solid var(--j-button-border, rgba(128, 128, 128, 0.3));
+ border-radius: 4px;
+ color: var(--j-text-color, inherit);
+ cursor: pointer;
+
+ &:hover {
+ background-color: var(--j-button-hover-bg, rgba(128, 128, 128, 0.3));
+ }
+`;
diff --git a/packages/jazz-tools/src/tools/exports.ts b/packages/jazz-tools/src/tools/exports.ts
index f806d7dd46..83607c9631 100644
--- a/packages/jazz-tools/src/tools/exports.ts
+++ b/packages/jazz-tools/src/tools/exports.ts
@@ -143,3 +143,5 @@ export * from "./ssr/index.js";
export { captureStack } from "./subscribe/errorReporting.js";
export * as jazzConfig from "./config.js";
+
+export { setOpenTelemetryInstrumentationEnabled as unstable_setOpenTelemetryInstrumentationEnabled } from "./lib/instrumentation.js";
diff --git a/packages/jazz-tools/src/tools/lib/instrumentation.ts b/packages/jazz-tools/src/tools/lib/instrumentation.ts
new file mode 100644
index 0000000000..091f6b7caf
--- /dev/null
+++ b/packages/jazz-tools/src/tools/lib/instrumentation.ts
@@ -0,0 +1,92 @@
+import { trace, type Span, context, type Histogram } from "@opentelemetry/api";
+import type { LoadMeasureDetail } from "./perf-utils";
+
+let otelEnabled = false;
+
+export function setOpenTelemetryInstrumentationEnabled(enable: boolean) {
+ otelEnabled = enable;
+}
+
+export function isOpenTelemetryInstrumentationEnabled() {
+ return otelEnabled;
+}
+
+export function recordLoadTimeToOTelMetric(
+ metric: Histogram,
+ loadMeasureDetail: LoadMeasureDetail,
+ coId: string,
+ error: boolean,
+) {
+ if (!otelEnabled) {
+ return;
+ }
+
+ metric.record(loadMeasureDetail.firstLoad.duration, {
+ id: coId,
+ result: error ? "error" : "loaded",
+ });
+}
+
+export function recordLoadTimeToOTelSpan(
+ subscriptionSpan: Span,
+ loadMeasureDetail: LoadMeasureDetail,
+) {
+ if (!otelEnabled) {
+ return;
+ }
+
+ const tracer = trace.getTracer("jazz-tools");
+ const subscriptionContext = trace.setSpan(context.active(), subscriptionSpan);
+
+ // Record the load span
+ const firstLoadSpan = tracer.startSpan(
+ "jazz.subscription.first_load",
+ {
+ startTime: performance.timeOrigin + loadMeasureDetail.firstLoad.startTime,
+ },
+ subscriptionContext,
+ );
+
+ const loadContext = trace.setSpan(subscriptionContext, firstLoadSpan);
+
+ if (loadMeasureDetail.loadFromStorage) {
+ tracer
+ .startSpan(
+ "jazz.subscription.first_load.from_storage",
+ {
+ startTime:
+ performance.timeOrigin +
+ loadMeasureDetail.loadFromStorage?.startTime,
+ },
+ loadContext,
+ )
+ .end(
+ performance.timeOrigin +
+ loadMeasureDetail.loadFromStorage?.startTime +
+ loadMeasureDetail.loadFromStorage?.duration,
+ );
+ }
+
+ if (loadMeasureDetail.loadFromPeer) {
+ tracer
+ .startSpan(
+ "jazz.subscription.first_load.load_from_peer",
+ {
+ startTime:
+ performance.timeOrigin + loadMeasureDetail.loadFromPeer?.startTime,
+ },
+ loadContext,
+ )
+ .end(
+ performance.timeOrigin +
+ loadMeasureDetail.loadFromPeer?.startTime +
+ loadMeasureDetail.loadFromPeer?.duration,
+ );
+ }
+
+ firstLoadSpan.end(
+ performance.timeOrigin +
+ loadMeasureDetail.firstLoad.startTime +
+ loadMeasureDetail.firstLoad.duration,
+ );
+}
diff --git a/packages/jazz-tools/src/tools/lib/perf-utils.ts b/packages/jazz-tools/src/tools/lib/perf-utils.ts
new file mode 100644
index 0000000000..8a7008ecdc
--- /dev/null
+++ b/packages/jazz-tools/src/tools/lib/perf-utils.ts
@@ -0,0 +1,181 @@
+import { perf as coJsonPerf } from "cojson";
+
+export const performanceMarks = {
+ subscriptionLoadStart: "jazz.subscription.first_load.start",
+ subscriptionLoadEnd: "jazz.subscription.first_load.end",
+} as const;
+
+export const performanceMeasures = {
+ subscriptionLoad: "jazz.subscription.first_load",
+ subscriptionLoadFromStorage: "jazz.subscription.load_from_storage",
+ subscriptionLoadFromPeer: "jazz.subscription.load_load_from_peer",
+} as const;
+
+const performanceMarkAvailable =
+ "mark" in performance && "getEntriesByName" in performance;
+
+export function trackPerformanceMark(
+ mark: keyof typeof performanceMarks,
+ coId: string,
+ detail?: Record,
+) {
+ if (!performanceMarkAvailable) {
+ return;
+ }
+
+ performance.mark(performanceMarks[mark] + "." + coId, {
+ detail,
+ });
+}
+
+export interface LoadMeasureDetail {
+ firstLoad: PerformanceMeasure;
+ loadFromStorage?: PerformanceMeasure;
+ loadFromPeer?: PerformanceMeasure;
+}
+
+export function measureSubscriptionLoad(
+ coId: string,
+ sourceId: string | undefined,
+ parentId: string | undefined,
+ parentKey: string | undefined,
+ resolve: any,
+): LoadMeasureDetail | null {
+ if (!performanceMarkAvailable) {
+ return null;
+ }
+
+ const loadMeasureDetail: Omit = {};
+
+ const loadFromStorage = extractStartEndMarks(
+ coJsonPerf.performanceMarks.loadFromStorageStart,
+ coJsonPerf.performanceMarks.loadFromStorageEnd,
+ coId,
+ );
+
+ if (loadFromStorage) {
+ // In case of missed value, we don't want to measure the load from storage
+ if (loadFromStorage.end.detail.found) {
+ loadMeasureDetail.loadFromStorage = performance.measure(
+ performanceMeasures.subscriptionLoadFromStorage + "." + coId,
+ {
+ start: loadFromStorage.start.startTime,
+ end: loadFromStorage.end.startTime,
+ detail: {
+ id: coId,
+ source_id: sourceId,
+ parent_id: parentId,
+ parent_key: parentKey,
+ resolve,
+ // devtools: {
+ // track: sourceId,
+ // trackGroup: "SubscriptionScopes",
+ // tooltipText: "Load from storage",
+ // color: "secondary",
+ // },
+ },
+ },
+ );
+ }
+ }
+
+ const loadFromPeer = extractStartEndMarks(
+ coJsonPerf.performanceMarks.loadFromPeerStart,
+ coJsonPerf.performanceMarks.loadFromPeerEnd,
+ coId,
+ );
+ if (loadFromPeer) {
+ loadMeasureDetail.loadFromPeer = performance.measure(
+ performanceMeasures.subscriptionLoadFromPeer + "." + coId,
+ {
+ start: loadFromPeer.start.startTime,
+ end: loadFromPeer.end.startTime,
+ detail: {
+ id: coId,
+ source_id: sourceId,
+ parent_id: parentId,
+ parent_key: parentKey,
+ resolve,
+ // devtools: {
+ // track: sourceId,
+ // trackGroup: "SubscriptionScopes",
+ // tooltipText: "Internal load from peer",
+ // color: "secondary",
+ // },
+ },
+ },
+ );
+ }
+
+ const firstLoad = extractStartEndMarks(
+ performanceMarks.subscriptionLoadStart,
+ performanceMarks.subscriptionLoadEnd,
+ coId,
+ );
+
+ if (!firstLoad) {
+ return null;
+ }
+
+ const loadMeasure = performance.measure(
+ performanceMeasures.subscriptionLoad + "." + coId,
+ {
+ start: firstLoad.start.startTime,
+ end: firstLoad.end.startTime,
+ detail: {
+ id: coId,
+ source_id: sourceId,
+ parent_id: parentId,
+ parent_key: parentKey,
+ resolve,
+ loadFromStorage: loadMeasureDetail.loadFromStorage?.duration,
+ loadFromPeer: loadMeasureDetail.loadFromPeer?.duration,
+ // Show in devtools only top-level subscriptions
+ devtools: parentId
+ ? undefined
+ : {
+ track: sourceId ?? coId,
+ trackGroup: "SubscriptionScopes",
+ tooltipText: "First load time",
+ color: "primary",
+ },
+ },
+ },
+ );
+
+ return {
+ ...loadMeasureDetail,
+ firstLoad: loadMeasure,
+ };
+}
+
+function extractStartEndMarks(
+ startMark: string,
+ endMark: string,
+ coId: string,
+): { start: PerformanceMark; end: PerformanceMark } | null {
+ const endMarks = performance.getEntriesByName(endMark + "." + coId, "mark");
+ const startMarks = performance.getEntriesByName(
+ startMark + "." + coId,
+ "mark",
+ );
+
+ // Assuming they are all sync, pick the last endMark entry position
+ const startMarkEntry = startMarks.at(startMarks.length - 1);
+ const endMarkEntry = endMarks.at(-1);
+
+ if (!startMarkEntry || !endMarkEntry) {
+ return null;
+ }
+
+ // clean up marks once they are synchronized
+ if (endMarks.length === startMarks.length) {
+ performance.clearMarks(startMark + "." + coId);
+ performance.clearMarks(endMark + "." + coId);
+ }
+
+ return {
+ start: startMarkEntry as PerformanceMark,
+ end: endMarkEntry as PerformanceMark,
+ };
+}
diff --git a/packages/jazz-tools/src/tools/subscribe/SubscriptionScope.ts b/packages/jazz-tools/src/tools/subscribe/SubscriptionScope.ts
index bec9f3ba64..c9529f808e 100644
--- a/packages/jazz-tools/src/tools/subscribe/SubscriptionScope.ts
+++ b/packages/jazz-tools/src/tools/subscribe/SubscriptionScope.ts
@@ -1,4 +1,12 @@
-import { LocalNode, RawCoValue } from "cojson";
+import {
+ type UpDownCounter,
+ ValueType,
+ metrics,
+ trace,
+ type Histogram,
+ type Span,
+} from "@opentelemetry/api";
+import type { LocalNode, RawCoValue } from "cojson";
import {
CoFeed,
CoList,
@@ -35,6 +43,15 @@ import {
rejectedPromise,
resolvedPromise,
} from "./utils.js";
+import {
+ measureSubscriptionLoad,
+ trackPerformanceMark,
+} from "../lib/perf-utils.js";
+import {
+ isOpenTelemetryInstrumentationEnabled,
+ recordLoadTimeToOTelMetric,
+ recordLoadTimeToOTelSpan,
+} from "../lib/instrumentation.js";
export class SubscriptionScope {
childNodes = new Map>();
@@ -66,6 +83,28 @@ export class SubscriptionScope {
migrated = false;
migrating = false;
closed = false;
+ constructionTime = performance.now();
+
+ private firstLoadRecorded = false;
+
+ private activeSubCounter: UpDownCounter = metrics
+ .getMeter("jazz-tools")
+ .createUpDownCounter("jazz.subscription.active", {
+ description: "The number of active subscriptions",
+ unit: "covalue",
+ valueType: ValueType.INT,
+ });
+
+ private firstLoadMetric: Histogram = metrics
+ .getMeter("jazz-tools")
+ .createHistogram("jazz.subscription.first_load", {
+ description:
+ "Time elapsed between SubscriptionScope construction and first TriggerUpdate",
+ unit: "ms",
+ valueType: ValueType.DOUBLE,
+ });
+
+ public readonly subscriptionSpan: Span | undefined;
silenceUpdates = false;
@@ -85,6 +124,9 @@ export class SubscriptionScope {
public bestEffortResolution = false,
public unstable_branch?: BranchDefinition,
callerStack?: Error | undefined,
+ private readonly sourceId?: ID,
+ private readonly parent?: ID,
+ private readonly parentKey?: string,
) {
// Use caller stack if provided, otherwise capture here (less useful but better than nothing)
this.callerStack = callerStack;
@@ -96,6 +138,29 @@ export class SubscriptionScope {
| typeof CoValueLoadingState.UNAVAILABLE
| undefined;
+ trackPerformanceMark("subscriptionLoadStart", id);
+
+ if (isOpenTelemetryInstrumentationEnabled()) {
+ this.subscriptionSpan = trace
+ .getTracer("jazz-tools")
+ .startSpan("jazz.subscription", {
+ attributes: {
+ id: this.id,
+ parent_id: this.parent,
+ parent_key: this.parentKey,
+ source_id: this.sourceId,
+ resolve: JSON.stringify(this.resolve),
+ },
+ });
+ }
+
+ // This OTel counter is also used to track the number of active subscriptions in the inspector
+ this.activeSubCounter.add(1, {
+ // It increments/decrements counters comparing the attributes
+ id: this.id,
+ source_id: this.sourceId,
+ resolve: JSON.stringify(this.resolve),
+ });
this.subscription = new CoValueCoreSubscription(
node,
id,
@@ -513,6 +578,9 @@ export class SubscriptionScope {
triggerUpdate() {
if (!this.shouldSendUpdates()) return;
if (!this.dirty) return;
+
+ this.trackFirstLoad();
+
if (this.subscribers.size === 0) return;
if (this.silenceUpdates) return;
@@ -528,6 +596,34 @@ export class SubscriptionScope {
this.dirty = false;
}
+ private trackFirstLoad() {
+ if (!this.firstLoadRecorded) {
+ trackPerformanceMark("subscriptionLoadEnd", this.id);
+
+ const loadMeasureDetail = measureSubscriptionLoad(
+ this.id,
+ this.sourceId,
+ this.parent,
+ this.parentKey,
+ this.resolve,
+ );
+
+ if (loadMeasureDetail) {
+ recordLoadTimeToOTelMetric(
+ this.firstLoadMetric,
+ loadMeasureDetail,
+ this.id,
+ !!this.errorFromChildren,
+ );
+ if (this.subscriptionSpan) {
+ recordLoadTimeToOTelSpan(this.subscriptionSpan, loadMeasureDetail);
+ }
+ }
+
+ this.firstLoadRecorded = true;
+ }
+ }
+
subscribers = new Set<(value: SubscriptionValue) => void>();
subscriberChangeCallbacks = new Set<(count: number) => void>();
@@ -685,6 +781,10 @@ export class SubscriptionScope {
this.skipRetry,
this.bestEffortResolution,
this.unstable_branch,
+ undefined,
+ this.sourceId ?? this.id,
+ this.id,
+ "direct-by-id",
);
this.childNodes.set(id, child);
child.setListener((value) => this.handleChildUpdate(id, value));
@@ -939,6 +1039,10 @@ export class SubscriptionScope {
this.skipRetry,
this.bestEffortResolution,
this.unstable_branch,
+ undefined,
+ this.sourceId ?? this.id,
+ this.id,
+ this.parentKey ? `${this.parentKey}.${key}` : key,
);
this.childNodes.set(id, child);
child.setListener((value) => this.handleChildUpdate(id, value, key));
@@ -965,6 +1069,13 @@ export class SubscriptionScope {
// Clear subscriber change callbacks to prevent memory leaks
this.subscriberChangeCallbacks.clear();
this.childNodes.forEach((child) => child.destroy());
+
+ this.subscriptionSpan?.end();
+ this.activeSubCounter.add(-1, {
+ id: this.id,
+ source_id: this.sourceId,
+ resolve: JSON.stringify(this.resolve),
+ });
}
}
diff --git a/packages/jazz-tools/src/tools/tests/SubscriptionScope.test.ts b/packages/jazz-tools/src/tools/tests/SubscriptionScope.test.ts
index cc8e80a92d..2fe8fe36b0 100644
--- a/packages/jazz-tools/src/tools/tests/SubscriptionScope.test.ts
+++ b/packages/jazz-tools/src/tools/tests/SubscriptionScope.test.ts
@@ -1,5 +1,9 @@
-import { beforeEach, describe, expect, it } from "vitest";
-import { Account, Group, co, z } from "../exports.js";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ co,
+ unstable_setOpenTelemetryInstrumentationEnabled,
+ z,
+} from "../exports.js";
import {
CoValueLoadingState,
coValueClassFromCoValueClassOrSchema,
@@ -7,6 +11,10 @@ import {
import { createJazzTestAccount, setupJazzTestSync } from "../testing.js";
import { JazzError } from "../subscribe/JazzError.js";
import { SubscriptionScope } from "../subscribe/SubscriptionScope.js";
+import {
+ createTestMetricReader,
+ tearDownTestMetricReader,
+} from "./testMetricUtils.js";
describe("SubscriptionScope", () => {
const Person = co.map({
@@ -394,4 +402,114 @@ describe("SubscriptionScope", () => {
scope.destroy();
});
});
+
+ describe("OTel metrics", () => {
+ let metricReader: ReturnType;
+
+ beforeEach(() => {
+ metricReader = createTestMetricReader();
+ });
+
+ afterEach(() => {
+ tearDownTestMetricReader();
+ });
+
+ it("tracks the number of active subscriptions", async () => {
+ const schema = {
+ ref: coValueClassFromCoValueClassOrSchema(Person),
+ optional: false,
+ };
+
+ const person = Person.create({ name: "John" });
+ const scope = new SubscriptionScope(
+ person.$jazz.raw.core.node,
+ true,
+ person.$jazz.id,
+ schema,
+ );
+
+ const dp = await metricReader.getMetricDataPoints(
+ "jazz-tools",
+ "jazz.subscription.active",
+ );
+ expect(dp).toHaveLength(1);
+ expect(dp[0]).toMatchObject({
+ attributes: {
+ id: person.$jazz.id,
+ },
+ value: 1,
+ });
+
+ expect(
+ await metricReader.getSumOfCounterMetric(
+ "jazz-tools",
+ "jazz.subscription.active",
+ ),
+ ).toBe(1);
+
+ const person2 = Person.create({ name: "John" });
+ const scope2 = new SubscriptionScope(
+ person2.$jazz.raw.core.node,
+ true,
+ person2.$jazz.id,
+ schema,
+ );
+
+ expect(
+ await metricReader.getSumOfCounterMetric(
+ "jazz-tools",
+ "jazz.subscription.active",
+ ),
+ ).toBe(2);
+
+ scope.destroy();
+
+ expect(
+ await metricReader.getSumOfCounterMetric(
+ "jazz-tools",
+ "jazz.subscription.active",
+ ),
+ ).toBe(1);
+
+ scope2.destroy();
+
+ expect(
+ await metricReader.getSumOfCounterMetric(
+ "jazz-tools",
+ "jazz.subscription.active",
+ ),
+ ).toBe(0);
+ });
+
+ it("tracks the duration of the first load", async () => {
+ unstable_setOpenTelemetryInstrumentationEnabled(true);
+ const schema = {
+ ref: coValueClassFromCoValueClassOrSchema(Person),
+ optional: false,
+ };
+
+ const person = Person.create({ name: "John" });
+ person.$jazz.owner.makePublic();
+
+ const account2 = await createJazzTestAccount();
+
+ const scope = new SubscriptionScope(
+ account2.$jazz.raw.core.node,
+ true,
+ person.$jazz.id,
+ schema,
+ );
+
+ expect(
+ await metricReader.getMetricValue(
+ "jazz-tools",
+ "jazz.subscription.first_load",
+ ),
+ ).toBeGreaterThan(0);
+
+ scope.destroy();
+
+ unstable_setOpenTelemetryInstrumentationEnabled(false);
+ });
+ });
});
diff --git a/packages/jazz-tools/src/tools/tests/testMetricUtils.ts b/packages/jazz-tools/src/tools/tests/testMetricUtils.ts
new file mode 100644
index 0000000000..716c6fa492
--- /dev/null
+++ b/packages/jazz-tools/src/tools/tests/testMetricUtils.ts
@@ -0,0 +1,103 @@
+import { metrics } from "@opentelemetry/api";
+import {
+ AggregationTemporality,
+ InMemoryMetricExporter,
+ MeterProvider,
+ MetricReader,
+} from "@opentelemetry/sdk-metrics";
+import { assert, expect } from "vitest";
+
+class TestMetricReader extends MetricReader {
+ private _exporter = new InMemoryMetricExporter(
+ AggregationTemporality.CUMULATIVE,
+ );
+
+ 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;
+
+ assert(dp, `Metric ${name} not found in scope ${scope}`);
+
+ return dp;
+ }
+
+ async getSumOfCounterMetric(scope: string, name: string) {
+ const dp = await this.getMetricDataPoints(scope, name);
+ return dp.reduce((acc, dp) => {
+ if (typeof dp.value !== "number") {
+ throw new Error(`Metric ${name} has a value that is not a number`);
+ }
+
+ return acc + dp.value;
+ }, 0);
+ }
+
+ async getMetricValue(
+ scope: string,
+ name: string,
+ attributes: { [key: string]: string | number } | null = null,
+ ) {
+ const dp1 = await this.getMetricDataPoints(scope, name);
+
+ const dp = attributes
+ ? dp1.find(
+ (dp) => JSON.stringify(dp.attributes) === JSON.stringify(attributes),
+ )
+ : dp1[0];
+
+ if (typeof dp?.value === "number") {
+ return dp.value;
+ }
+
+ if (typeof dp?.value === "object") {
+ return dp.value.sum;
+ }
+
+ return dp?.value;
+ }
+
+ 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 function createTestMetricReader() {
+ const metricReader = new TestMetricReader();
+ const success = metrics.setGlobalMeterProvider(
+ new MeterProvider({
+ readers: [metricReader],
+ }),
+ );
+
+ expect(success).toBe(true);
+
+ return metricReader;
+}
+
+export function tearDownTestMetricReader() {
+ metrics.disable();
+}