From d881a41061b7d22722e03863b0f53d2618ef4816 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 15:42:11 +0100 Subject: [PATCH 1/9] docs(onboarding): gRPC works on port 443, add a Ruby and Rails guide --- .../src/components/docs/LanguageLogo.astro | 2 + .../src/components/icons/RubyLogo.astro | 17 ++ .../src/content/docs/agent-tracing.mdx | 4 +- .../docs/agent-tracing/claude-agent-sdk.mdx | 2 +- .../microsoft-agent-framework.mdx | 4 +- .../docs/getting-started/introduction.md | 3 +- .../docs/getting-started/quickstart.mdx | 44 ++++- .../docs/guides/instrumentation-csharp.md | 4 +- .../content/docs/guides/instrumentation-go.md | 2 +- .../docs/guides/instrumentation-java.md | 2 +- .../docs/guides/instrumentation-kotlin.md | 2 +- .../docs/guides/instrumentation-nextjs.md | 2 +- .../docs/guides/instrumentation-nodejs.md | 2 +- .../docs/guides/instrumentation-python.md | 4 +- .../docs/guides/instrumentation-ruby.md | 187 ++++++++++++++++++ .../docs/guides/instrumentation-rust.md | 2 +- .../src/content/docs/instrumentation.mdx | 4 +- .../docs/integrations/gcp-opentelemetry.md | 2 +- .../src/content/docs/reference/ingest.md | 14 +- apps/landing/src/lib/brand-marks.ts | 5 + apps/landing/src/lib/docs-languages.ts | 2 + apps/landing/src/lib/docs-og.ts | 1 + .../landing/src/lib/instrumentation-guides.ts | 3 +- apps/landing/src/lib/integrations-hub.ts | 7 + apps/web/src/lib/docs.ts | 1 + 25 files changed, 292 insertions(+), 30 deletions(-) create mode 100644 apps/landing/src/components/icons/RubyLogo.astro create mode 100644 apps/landing/src/content/docs/guides/instrumentation-ruby.md diff --git a/apps/landing/src/components/docs/LanguageLogo.astro b/apps/landing/src/components/docs/LanguageLogo.astro index 5a255b1305..c3e5450ae2 100644 --- a/apps/landing/src/components/docs/LanguageLogo.astro +++ b/apps/landing/src/components/docs/LanguageLogo.astro @@ -13,6 +13,7 @@ import OpenJdkLogo from "../icons/OpenJdkLogo.astro"; import CsharpLogo from "../icons/CsharpLogo.astro"; import KotlinLogo from "../icons/KotlinLogo.astro"; import LaravelLogo from "../icons/LaravelLogo.astro"; +import RubyLogo from "../icons/RubyLogo.astro"; import TypeScriptLogo from "../icons/TypeScriptLogo.astro"; interface Props { @@ -31,6 +32,7 @@ const LOGOS = { kotlin: KotlinLogo, csharp: CsharpLogo, laravel: LaravelLogo, + ruby: RubyLogo, typescript: TypeScriptLogo, } satisfies Record; diff --git a/apps/landing/src/components/icons/RubyLogo.astro b/apps/landing/src/components/icons/RubyLogo.astro new file mode 100644 index 0000000000..2d973f29bb --- /dev/null +++ b/apps/landing/src/components/icons/RubyLogo.astro @@ -0,0 +1,17 @@ +--- +interface Props { + class?: string; +} + +const { class: className = "w-3.5 h-3.5" } = Astro.props; +--- + + diff --git a/apps/landing/src/content/docs/agent-tracing.mdx b/apps/landing/src/content/docs/agent-tracing.mdx index 8724ad0987..a4a352d45b 100644 --- a/apps/landing/src/content/docs/agent-tracing.mdx +++ b/apps/landing/src/content/docs/agent-tracing.mdx @@ -44,7 +44,7 @@ If you use a gateway like OpenRouter or LiteLLM together with a framework, instr ## Connection details -Every guide exports OTLP over HTTP with an ingest key from **Settings → Ingestion**: +Every guide exports OTLP with an ingest key from **Settings → Ingestion**: ```bash export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.maple.dev" # https://ingest.eu.maple.dev for EU organizations @@ -53,7 +53,7 @@ export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer YOUR_INGEST_KEY" export OTEL_SERVICE_NAME="support-agent" ``` -Maple doesn't accept gRPC, so set the protocol to `http/protobuf` if your exporter defaults to it. A traces endpoint passed in code or through `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` must end in `/v1/traces`. +The guides use `http/protobuf` because their install steps add the HTTP exporter. Maple also accepts OTLP over gRPC on the same host and port 443, so `grpc` works if you install a gRPC exporter instead. A traces endpoint passed in code or through `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` must end in `/v1/traces`. Keep agent traffic at 100% sampling, or sampled-out turns leave gaps in the session. See [Sampling and throughput](/docs/concepts/sampling-throughput). diff --git a/apps/landing/src/content/docs/agent-tracing/claude-agent-sdk.mdx b/apps/landing/src/content/docs/agent-tracing/claude-agent-sdk.mdx index 69e9cc287b..ec995746c8 100644 --- a/apps/landing/src/content/docs/agent-tracing/claude-agent-sdk.mdx +++ b/apps/landing/src/content/docs/agent-tracing/claude-agent-sdk.mdx @@ -238,7 +238,7 @@ The transcript has no assistant replies and cost shows as unpriced. Both are on ## Troubleshooting - **Metrics and logs arrive, but no sessions.** Set `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1` and `OTEL_TRACES_EXPORTER=otlp`. -- **Nothing arrives at all.** `OTEL_EXPORTER_OTLP_PROTOCOL` is unset or `grpc`. Set `http/protobuf`. `CLAUDE_CODE_OTEL_DIAG_STDERR=1` prints export errors to the SDK's `stderr` callback. +- **Nothing arrives at all.** `OTEL_EXPORTER_OTLP_PROTOCOL` is unset. Set `http/protobuf`. `CLAUDE_CODE_OTEL_DIAG_STDERR=1` prints export errors to the SDK's `stderr` callback. - **Every message is its own session.** Resume the conversation's session id as shown above, and don't set `forkSession`. - **Your agent's turns appear inside another trace.** The process inherited a `TRACEPARENT`. Drop it and `TRACESTATE` from the environment. - **The CLI ignores your values.** They are in a repository's `.claude/settings.json`, or a settings file overrides `options.env`. Use `~/.claude/settings.json`, or pass `settingSources: []`. diff --git a/apps/landing/src/content/docs/agent-tracing/microsoft-agent-framework.mdx b/apps/landing/src/content/docs/agent-tracing/microsoft-agent-framework.mdx index 825b3b8276..ba25a47106 100644 --- a/apps/landing/src/content/docs/agent-tracing/microsoft-agent-framework.mdx +++ b/apps/landing/src/content/docs/agent-tracing/microsoft-agent-framework.mdx @@ -69,7 +69,7 @@ else: logging.getLogger(__name__).warning("MAPLE_INGEST_KEY is not set; Maple telemetry export is disabled") ``` -Keep `otlp_protocol="http/protobuf"`. MAF defaults to gRPC, which Maple doesn't accept. +MAF defaults to gRPC, which needs the `opentelemetry-exporter-otlp-proto-grpc` package. Maple accepts both protocols on the same endpoint, so `http/protobuf` here only avoids that extra install. If your app already has a `TracerProvider`, don't call `configure_otel_providers()`. Add Maple's exporter and `ConversationIdProcessor` to your provider, then call `enable_instrumentation(enable_sensitive_data=True, enable_message_events=False)` from `agent_framework.observability`. @@ -289,7 +289,7 @@ Run a conversation of two or three turns where one turn calls a tool. Within abo ## Troubleshooting -- **Nothing arrives, or `ImportError: opentelemetry-exporter-otlp-proto-grpc is required`.** The protocol defaulted to gRPC. Set `otlp_protocol="http/protobuf"` or `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf`. +- **`ImportError: opentelemetry-exporter-otlp-proto-grpc is required`.** The protocol defaulted to gRPC and the gRPC exporter is not installed. Set `otlp_protocol="http/protobuf"` (or `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf`), or install `opentelemetry-exporter-otlp-proto-grpc`. Both protocols reach Maple. - **Every turn is its own session.** Register `ConversationIdProcessor` and wrap each call, including the whole streaming loop, in `conversation()`. - **Spans but an empty transcript.** Sensitive data is off, or `OTEL_SEMCONV_STABILITY_OPT_IN` is set without `gen_ai_latest_experimental` (use `http,gen_ai_latest_experimental`). - **.NET: no spans at all.** Use `AddSource("*Microsoft.Agents.AI*")` with the leading `*`. diff --git a/apps/landing/src/content/docs/getting-started/introduction.md b/apps/landing/src/content/docs/getting-started/introduction.md index d0abe61740..c7d9b1988e 100644 --- a/apps/landing/src/content/docs/getting-started/introduction.md +++ b/apps/landing/src/content/docs/getting-started/introduction.md @@ -49,7 +49,7 @@ With the standard OpenTelemetry environment variables, step 3 looks like this: ```bash export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.maple.dev" # EU organizations use https://ingest.eu.maple.dev -export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf" +export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf" # or grpc, to match your exporter package export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer YOUR_INGEST_KEY" export OTEL_SERVICE_NAME="my-service" ``` @@ -68,6 +68,7 @@ If you use a coding agent, the [maple-onboard](https://github.com/MapleTechLabs/ - [C# / .NET](/docs/guides/instrumentation-csharp): ASP.NET Core, HttpClient, Entity Framework Core - [Kotlin](/docs/guides/instrumentation-kotlin): Ktor and Spring Boot - [Laravel](/docs/guides/instrumentation-laravel): Eloquent, queues, HTTP client +- [Ruby](/docs/guides/instrumentation-ruby): Rails, Sinatra, Sidekiq The attributes Maple reads for the service map, error grouping and throughput are listed in [OpenTelemetry conventions](/docs/concepts/otel-conventions). diff --git a/apps/landing/src/content/docs/getting-started/quickstart.mdx b/apps/landing/src/content/docs/getting-started/quickstart.mdx index 4c4d236257..5bb73b6284 100644 --- a/apps/landing/src/content/docs/getting-started/quickstart.mdx +++ b/apps/landing/src/content/docs/getting-started/quickstart.mdx @@ -31,11 +31,10 @@ Every OpenTelemetry SDK reads these standard variables: export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.maple.dev" # EU organizations use https://ingest.eu.maple.dev export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer YOUR_INGEST_KEY" -export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf" export OTEL_SERVICE_NAME="quickstart" ``` -The endpoint is the base URL. Exporters append `/v1/traces`, `/v1/logs` and `/v1/metrics` themselves. +The endpoint is the base URL. Maple takes OTLP over HTTP (protobuf or JSON) and OTLP over gRPC on the same host and port 443, so your SDK's default protocol works as long as the exporter package for it is installed. HTTP exporters append `/v1/traces`, `/v1/logs` and `/v1/metrics` themselves; gRPC exporters use the base URL as is. You never need port `4317`. ## 4. Send a trace @@ -51,6 +50,7 @@ Pick your stack. Each setup reads the variables from step 3, so run it in the sa { id: "java", label: "Java" }, { id: "csharp", label: ".NET" }, { id: "laravel", label: "Laravel" }, + { id: "ruby", label: "Ruby" }, { id: "effect", label: "Effect" }, { id: "curl", label: "curl" }, ]} @@ -101,14 +101,15 @@ pip install opentelemetry-distro opentelemetry-exporter-otlp-proto-http opentelemetry-bootstrap -a install ``` -Start your app through the `opentelemetry-instrument` wrapper. No code changes: +Set the protocol to match the HTTP exporter installed above (the distro defaults to gRPC, which needs `opentelemetry-exporter-otlp-proto-grpc` instead), then start your app through the `opentelemetry-instrument` wrapper. No code changes: ```bash +export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf" opentelemetry-instrument python app.py # or: opentelemetry-instrument uvicorn main:app ``` -Send a request to your app. FastAPI, Django, Flask and the common HTTP and database clients are traced automatically. Keep `OTEL_EXPORTER_OTLP_PROTOCOL` from step 3: the distro defaults to gRPC, which Maple does not accept. The wrapper does not work with `uvicorn --reload`. For logs, metrics and a setup in code, see the [Python guide](/docs/guides/instrumentation-python). +Send a request to your app. FastAPI, Django, Flask and the common HTTP and database clients are traced automatically. The wrapper does not work with `uvicorn --reload`. For logs, metrics and a setup in code, see the [Python guide](/docs/guides/instrumentation-python). @@ -221,11 +222,38 @@ Laravel reads its configuration from `.env`, so put the values from step 3 there # .env OTEL_SERVICE_NAME=quickstart OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.maple.dev -OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer YOUR_INGEST_KEY" ``` -Send a request to your app. Each request becomes a trace with spans for database queries, Redis commands and `Http` facade calls. If protobuf export fails in your environment, set `OTEL_EXPORTER_OTLP_PROTOCOL=http/json`. For queues, logs and metrics, see the [Laravel guide](/docs/guides/instrumentation-laravel). +Send a request to your app. Each request becomes a trace with spans for database queries, Redis commands and `Http` facade calls. The PHP SDK sends `http/protobuf` by default. If protobuf export fails in your environment, set `OTEL_EXPORTER_OTLP_PROTOCOL=http/json`. For queues, logs and metrics, see the [Laravel guide](/docs/guides/instrumentation-laravel). + + + + + +Add the SDK, the OTLP exporter and the instrumentation bundle to your `Gemfile`, then run `bundle install`: + +```ruby +# Gemfile +gem "opentelemetry-sdk" +gem "opentelemetry-exporter-otlp" +gem "opentelemetry-instrumentation-all" +``` + +In a Rails app, create an initializer. It reads the endpoint, headers and service name from the variables: + +```ruby +# config/initializers/opentelemetry.rb +require "opentelemetry/sdk" +require "opentelemetry/exporter/otlp" +require "opentelemetry/instrumentation/all" + +OpenTelemetry::SDK.configure do |c| + c.use_all +end +``` + +Start the server with `bin/rails server` and send a request. Each request becomes a trace with spans for Active Record queries, outgoing HTTP calls and Sidekiq jobs. The Ruby exporter sends `http/protobuf` only, so leave `OTEL_EXPORTER_OTLP_PROTOCOL` unset. For Sinatra, plain Ruby and Sidekiq, see the [Ruby guide](/docs/guides/instrumentation-ruby). @@ -301,8 +329,8 @@ Open **Explore → Traces** and filter **Service** to `quickstart` (or your `OTE ## Troubleshooting -- **The ingest endpoint answers 401.** The key is wrong, or it belongs to the other region. A US key is rejected by `ingest.eu.maple.dev` and the other way around. See [Regions](/docs/reference/regions). -- **Nothing shows up.** Maple accepts OTLP over HTTP only. An exporter set to `grpc` or pointed at port `4317` cannot reach it; use `http/protobuf` and the endpoint without a port. Set `OTEL_LOG_LEVEL=debug` to print export errors. +- **The ingest endpoint answers 401.** The key is wrong, or it belongs to the other region. A US key is rejected by `ingest.eu.maple.dev` and the other way around. The US endpoint's error message reads `Invalid ingest key. Keys are region-specific: if your Maple org is in the EU, send to https://ingest.eu.maple.dev.` The EU endpoint's message points to `https://ingest.maple.dev` instead. See [Regions](/docs/reference/regions). +- **Nothing shows up.** Check that the endpoint has no port. Maple serves OTLP over HTTP and gRPC on port 443, so an endpoint ending in `:4317` or `:4318` does not connect. Set `OTEL_LOG_LEVEL=debug` (or your SDK's equivalent) to print export errors. - **The trace shows up under the wrong service name.** A name like `unknown_service` means `OTEL_SERVICE_NAME` was not set in the shell (or `.env`) that started the app, so the SDK fell back to a default `service.name`. - **Still nothing.** Check the time range on the Traces page. For the curl span, your machine's clock sets the timestamp. diff --git a/apps/landing/src/content/docs/guides/instrumentation-csharp.md b/apps/landing/src/content/docs/guides/instrumentation-csharp.md index f09681ac74..db6f7a3918 100644 --- a/apps/landing/src/content/docs/guides/instrumentation-csharp.md +++ b/apps/landing/src/content/docs/guides/instrumentation-csharp.md @@ -73,7 +73,7 @@ app.MapGet("/", () => "Hello!"); app.Run(); ``` -When you set `Endpoint` in code, it must include the signal path (`/v1/traces`, `/v1/metrics`, `/v1/logs`). The exporter's default protocol is gRPC, so set `HttpProtobuf` on each one. +When you set `Endpoint` in code, it must include the signal path (`/v1/traces`, `/v1/metrics`, `/v1/logs`). The exporter's default protocol is gRPC, which needs the base URL without a signal path, so the example sets `HttpProtobuf` on each one to keep the paths. Maple accepts either protocol on port 443. The example puts the endpoint and key in source. An ingest key can only write telemetry to your organization. It cannot read data or call the Maple API. Keeping it in source means the SDK always starts with a complete configuration, so a deploy that is missing an environment variable cannot silently turn telemetry off. To keep the key out of source, use [environment variables](#environment-variables) instead. @@ -224,7 +224,7 @@ Your service also appears on the **Services** page once its first spans arrive. ## Troubleshooting - **`401` responses.** The key is wrong, was copied from the other region, or the header is malformed. `OtlpExporterOptions.Headers` and `OTEL_EXPORTER_OTLP_HEADERS` both take `Authorization=Bearer YOUR_INGEST_KEY`. See [Ingest API status codes](/docs/reference/ingest#status-codes). -- **Wrong protocol or path.** Maple accepts OTLP over HTTP, and the .NET exporter defaults to gRPC. Set `OtlpExportProtocol.HttpProtobuf` in code or `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf`. An endpoint set in code needs the full signal path; the environment variable takes only the base URL. +- **Wrong path or port.** Maple accepts OTLP over HTTP and gRPC on port 443, so never add `:4317`. With `HttpProtobuf`, an endpoint set in code needs the full signal path; the environment variable takes only the base URL. With gRPC (the .NET default), use the base URL with no path. - **Network.** From the machine running the app, run `curl -i https://ingest.maple.dev/v1/traces -X POST`. Any HTTP status code means the host can reach Maple. A timeout or DNS error means a firewall or proxy is blocking outbound HTTPS. - **Nothing exported.** Custom spans only export when their `ActivitySource` name is registered with `AddSource`, and custom metrics when their `Meter` name is registered with `AddMeter`. In console apps, dispose the providers before exit so the last batch is sent. diff --git a/apps/landing/src/content/docs/guides/instrumentation-go.md b/apps/landing/src/content/docs/guides/instrumentation-go.md index af34e2ef3c..6c8a7f37d1 100644 --- a/apps/landing/src/content/docs/guides/instrumentation-go.md +++ b/apps/landing/src/content/docs/guides/instrumentation-go.md @@ -295,7 +295,7 @@ Your service also appears on the **Services** page once its first spans arrive. ## Troubleshooting - **`401` responses.** The key is wrong, was copied from the other region, or the header is malformed. The header must be `Authorization: Bearer YOUR_INGEST_KEY`. In `OTEL_EXPORTER_OTLP_HEADERS` it is written `Authorization=Bearer YOUR_INGEST_KEY`. See [Ingest API status codes](/docs/reference/ingest#status-codes). -- **Wrong protocol or path.** Maple accepts OTLP over HTTP. Use the `otlp*http` exporters, not `otlp*grpc`. `WithEndpointURL` takes the full URL including `/v1/traces`; `OTEL_EXPORTER_OTLP_ENDPOINT` takes only the base URL. +- **Wrong path or port.** Maple accepts OTLP over HTTP and gRPC on port 443. This guide uses the `otlp*http` exporters; the `otlp*grpc` ones also work with the base URL and no port. For the HTTP exporters, `WithEndpointURL` takes the full URL including `/v1/traces`; `OTEL_EXPORTER_OTLP_ENDPOINT` takes only the base URL. - **Network.** From the machine running the app, run `curl -i https://ingest.maple.dev/v1/traces -X POST`. Any HTTP status code means the host can reach Maple. A timeout or DNS error means a firewall or proxy is blocking outbound HTTPS. - **Nothing exported.** Call the shutdown function before `main` returns so the batchers flush. Code that calls `os.Exit` or `log.Fatal` skips deferred calls. Register an error handler with `otel.SetErrorHandler` to print export failures. diff --git a/apps/landing/src/content/docs/guides/instrumentation-java.md b/apps/landing/src/content/docs/guides/instrumentation-java.md index 5ae8dc36cb..365f129614 100644 --- a/apps/landing/src/content/docs/guides/instrumentation-java.md +++ b/apps/landing/src/content/docs/guides/instrumentation-java.md @@ -272,7 +272,7 @@ Your service also appears on the **Services** page once its first spans arrive. ## Troubleshooting - **`401` responses.** The key is wrong, was copied from the other region, or the header is malformed. The header must be `Authorization: Bearer YOUR_INGEST_KEY`. In `otel.exporter.otlp.headers` it is written `Authorization=Bearer YOUR_INGEST_KEY`. See [Ingest API status codes](/docs/reference/ingest#status-codes). -- **Wrong protocol or path.** Maple's [ingest API](/docs/reference/ingest) is OTLP over HTTP, so keep `otel.exporter.otlp.protocol` at `http/protobuf`. The agent endpoint is the base URL, while `setEndpoint` in the manual SDK takes the full signal path (`/v1/traces`). +- **Wrong path or port.** Maple's [ingest API](/docs/reference/ingest) accepts `http/protobuf` and `grpc` on port 443, so never add `:4317` or `:4318`. The agent endpoint is the base URL, while `setEndpoint` in the manual SDK takes the full signal path (`/v1/traces`). - **Network.** From the machine running the app, run `curl -i https://ingest.maple.dev/v1/traces -X POST`. Any HTTP status code means the host can reach Maple. A timeout or DNS error means a firewall or proxy is blocking outbound HTTPS. - **Nothing exported.** Check the startup log for a line from the OpenTelemetry agent; if it is missing, the `-javaagent` flag is not reaching the JVM. Add `-Dotel.javaagent.debug=true` to print spans and export errors. diff --git a/apps/landing/src/content/docs/guides/instrumentation-kotlin.md b/apps/landing/src/content/docs/guides/instrumentation-kotlin.md index 8fc0485570..6cea79cd73 100644 --- a/apps/landing/src/content/docs/guides/instrumentation-kotlin.md +++ b/apps/landing/src/content/docs/guides/instrumentation-kotlin.md @@ -299,7 +299,7 @@ Your service also appears on the **Services** page once its first spans arrive. ## Troubleshooting - **`401` responses.** The key is wrong, was copied from the other region, or the header is malformed. The header must be `Authorization: Bearer YOUR_INGEST_KEY`. In `otel.exporter.otlp.headers` it is written `Authorization=Bearer YOUR_INGEST_KEY`. See [Ingest API status codes](/docs/reference/ingest#status-codes). -- **Wrong protocol or path.** Maple's [ingest API](/docs/reference/ingest) is OTLP over HTTP, so keep `otel.exporter.otlp.protocol` at `http/protobuf`. The agent endpoint is the base URL, while `setEndpoint` in the manual SDK takes the full signal path (`/v1/traces`). +- **Wrong path or port.** Maple's [ingest API](/docs/reference/ingest) accepts `http/protobuf` and `grpc` on port 443, so never add `:4317` or `:4318`. The agent endpoint is the base URL, while `setEndpoint` in the manual SDK takes the full signal path (`/v1/traces`). - **Network.** From the machine running the app, run `curl -i https://ingest.maple.dev/v1/traces -X POST`. Any HTTP status code means the host can reach Maple. A timeout or DNS error means a firewall or proxy is blocking outbound HTTPS. - **Nothing exported.** Check the startup log for a line from the OpenTelemetry agent; if it is missing, the `-javaagent` flag is not reaching the JVM. Add `-Dotel.javaagent.debug=true` to print spans and export errors. diff --git a/apps/landing/src/content/docs/guides/instrumentation-nextjs.md b/apps/landing/src/content/docs/guides/instrumentation-nextjs.md index bdf1e4c575..b8d7db9bf4 100644 --- a/apps/landing/src/content/docs/guides/instrumentation-nextjs.md +++ b/apps/landing/src/content/docs/guides/instrumentation-nextjs.md @@ -205,7 +205,7 @@ Your service also appears on the **Services** page once its first spans arrive. ## Troubleshooting - **`401` responses.** The key is wrong, was copied from the other region, or the header is malformed. The header must be `Authorization: Bearer YOUR_INGEST_KEY`. In `OTEL_EXPORTER_OTLP_HEADERS` it is written `Authorization=Bearer YOUR_INGEST_KEY`. See [Ingest API status codes](/docs/reference/ingest#status-codes). -- **Wrong protocol or path.** Maple accepts OTLP over HTTP. Endpoints set in code need the full signal path (`/v1/traces`); `OTEL_EXPORTER_OTLP_ENDPOINT` takes only the base URL. +- **Wrong path or port.** Maple accepts OTLP over HTTP and gRPC on port 443, so the endpoint has no port. Endpoints set in code need the full signal path (`/v1/traces`); `OTEL_EXPORTER_OTLP_ENDPOINT` takes only the base URL. - **Network.** From the machine running the app, run `curl -i https://ingest.maple.dev/v1/traces -X POST`. Any HTTP status code means the host can reach Maple. A timeout or DNS error means a firewall or proxy is blocking outbound HTTPS. - **Nothing exported.** Check that `instrumentation.ts` is in the project root, or in `src/` for a `src` layout, and not inside `app/` or `pages/`. On Next.js 13.4 and 14, check that `experimental.instrumentationHook` is set. diff --git a/apps/landing/src/content/docs/guides/instrumentation-nodejs.md b/apps/landing/src/content/docs/guides/instrumentation-nodejs.md index 45a4cade05..21ec075b0d 100644 --- a/apps/landing/src/content/docs/guides/instrumentation-nodejs.md +++ b/apps/landing/src/content/docs/guides/instrumentation-nodejs.md @@ -236,7 +236,7 @@ Your service also appears on the **Services** page once its first spans arrive. ## Troubleshooting - **`401` responses.** The key is wrong, was copied from the other region, or the header is malformed. The header must be `Authorization: Bearer YOUR_INGEST_KEY`. In `OTEL_EXPORTER_OTLP_HEADERS` it is written `Authorization=Bearer YOUR_INGEST_KEY`. See [Ingest API status codes](/docs/reference/ingest#status-codes). -- **Wrong protocol or path.** Maple accepts OTLP over HTTP. Use the `-proto` (or `-http`) exporters, not `-grpc`. Endpoints set in code need the full signal path (`/v1/traces`); `OTEL_EXPORTER_OTLP_ENDPOINT` takes only the base URL. +- **Wrong path or port.** Maple accepts OTLP over HTTP and gRPC on port 443. The `-proto` and `-http` exporters are the usual choice; the `-grpc` ones also work with the base URL and no port. HTTP endpoints set in code need the full signal path (`/v1/traces`); `OTEL_EXPORTER_OTLP_ENDPOINT` takes only the base URL. - **Network.** From the machine running the app, run `curl -i https://ingest.maple.dev/v1/traces -X POST`. Any HTTP status code means the host can reach Maple. A timeout or DNS error means a firewall or proxy is blocking outbound HTTPS. - **Nothing exported.** `tracing.mjs` must load before the modules it instruments, so use `--import` rather than importing it from your app. A process that exits right away can lose its last batch; call `sdk.shutdown()` before exit, as the `SIGTERM` handler above does. Set `OTEL_LOG_LEVEL=debug` to print export errors to the console. diff --git a/apps/landing/src/content/docs/guides/instrumentation-python.md b/apps/landing/src/content/docs/guides/instrumentation-python.md index 59113147b6..45bbc2c6fe 100644 --- a/apps/landing/src/content/docs/guides/instrumentation-python.md +++ b/apps/landing/src/content/docs/guides/instrumentation-python.md @@ -115,7 +115,7 @@ export OTEL_RESOURCE_ATTRIBUTES="deployment.environment.name=production,vcs.repo opentelemetry-instrument python app.py ``` -`OTEL_EXPORTER_OTLP_PROTOCOL` matters here: the distro defaults to gRPC, which needs a different exporter package. `OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED` attaches the log exporter to the standard `logging` module. Use either this path or `telemetry.py`, not both. +`OTEL_EXPORTER_OTLP_PROTOCOL` matters here: the distro defaults to gRPC, which needs `opentelemetry-exporter-otlp-proto-grpc` instead. Maple accepts both protocols on the same endpoint. `OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED` attaches the log exporter to the standard `logging` module. Use either this path or `telemetry.py`, not both. ## Auto-instrumentation @@ -251,7 +251,7 @@ Your service also appears on the **Services** page once its first spans arrive. ## Troubleshooting - **`401` responses.** The key is wrong, was copied from the other region, or the header is malformed. The header must be `Authorization: Bearer YOUR_INGEST_KEY`. In `OTEL_EXPORTER_OTLP_HEADERS` it is written `Authorization=Bearer YOUR_INGEST_KEY`. See [Ingest API status codes](/docs/reference/ingest#status-codes). -- **Wrong protocol or path.** Maple accepts OTLP over HTTP. Use `opentelemetry-exporter-otlp-proto-http`, and set `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf` when using `opentelemetry-instrument`. Endpoints set in code need the full signal path (`/v1/traces`); `OTEL_EXPORTER_OTLP_ENDPOINT` takes only the base URL. +- **Wrong protocol or path.** The protocol must match the exporter package you installed: `http/protobuf` for `opentelemetry-exporter-otlp-proto-http`, `grpc` for `opentelemetry-exporter-otlp-proto-grpc`. Maple accepts both on port 443, so the endpoint has no port. HTTP endpoints set in code need the full signal path (`/v1/traces`); `OTEL_EXPORTER_OTLP_ENDPOINT` takes only the base URL. - **Network.** From the machine running the app, run `curl -i https://ingest.maple.dev/v1/traces -X POST`. Any HTTP status code means the host can reach Maple. A timeout or DNS error means a firewall or proxy is blocking outbound HTTPS. - **Nothing exported.** Import `telemetry` before the framework. With gunicorn or uWSGI worker processes, set up the SDK in each worker (for example in gunicorn's `post_fork` hook), because batch processor threads do not survive a fork. A short script can exit before its batch is sent; call `tracer_provider.shutdown()`, `logger_provider.shutdown()` and the meter provider's `shutdown()` before exit. diff --git a/apps/landing/src/content/docs/guides/instrumentation-ruby.md b/apps/landing/src/content/docs/guides/instrumentation-ruby.md new file mode 100644 index 0000000000..2688875f37 --- /dev/null +++ b/apps/landing/src/content/docs/guides/instrumentation-ruby.md @@ -0,0 +1,187 @@ +--- +title: "Ruby instrumentation" +description: "Instrument a Ruby or Rails application with OpenTelemetry and send traces to Maple." +group: "Instrumentation" +order: 14 +navLabel: "Ruby" +sdk: "ruby" +--- + +This guide sets up the OpenTelemetry Ruby SDK so your Rails, Sinatra or plain Ruby application sends traces to Maple, with instrumentation for Rails, Active Record, Sidekiq, Net::HTTP and the other libraries in `opentelemetry-instrumentation-all`. + +To have a coding agent do this setup, use the [maple-onboard](https://github.com/MapleTechLabs/maple/tree/main/skills/maple-onboard) skill, and [maple-audit](https://github.com/MapleTechLabs/maple/tree/main/skills/maple-audit) to check an existing setup. + +## Prerequisites + +- Ruby 3.3+ (the version current releases of `opentelemetry-sdk` require) +- Bundler +- An ingest key from **Settings → Ingestion** in Maple. Use the private key (`maple_sk_…`) for server applications. + +## Install + +Add the SDK, the OTLP exporter and the instrumentation bundle to your `Gemfile`: + +```ruby +# Gemfile +gem "opentelemetry-sdk" +gem "opentelemetry-exporter-otlp" +gem "opentelemetry-instrumentation-all" +``` + +```bash +bundle install +``` + +`opentelemetry-instrumentation-all` pulls in the instrumentation for every library it supports. Each one only turns on when its library is loaded, so unused ones cost nothing. + +## Configure Rails + +Create an initializer. It runs in every Rails process, including the console, runners and Sidekiq workers: + +```ruby +# config/initializers/opentelemetry.rb +require "opentelemetry/sdk" +require "opentelemetry/exporter/otlp" +require "opentelemetry/instrumentation/all" + +OpenTelemetry::SDK.configure do |c| + c.service_name = "my-rails-app" + c.use_all +end +``` + +`use_all` enables every installed instrumentation: Rails (Action Pack, Action View, Active Record, Active Job, Active Support), Rack, Net::HTTP, Faraday, `pg`, `mysql2`, Redis, Sidekiq and more. Each request becomes a server span, named after the method and route, with child spans for the queries and outgoing calls it made. + +The batch span processor restarts its export thread after a fork, so Puma in cluster mode and other forking servers need no extra setup. + +## Environment variables + +The SDK and the exporter read the standard variables, so the endpoint and key stay out of source: + +```bash +export OTEL_SERVICE_NAME="my-rails-app" +export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.maple.dev" +export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer YOUR_INGEST_KEY" +export OTEL_RESOURCE_ATTRIBUTES="deployment.environment.name=production,vcs.repository.url.full=https://github.com/acme/my-rails-app" +``` + +EU organizations use `https://ingest.eu.maple.dev`. The exporter appends `/v1/traces` to the endpoint itself. `OTEL_SERVICE_NAME` overrides the `service_name` set in the initializer, so you can drop that line once the variable is set everywhere. + +The Ruby exporter speaks OTLP over HTTP with protobuf encoding only. Leave `OTEL_EXPORTER_OTLP_PROTOCOL` unset or set it to `http/protobuf`; with `grpc` the SDK logs a warning and exports nothing. To compress batches, set `OTEL_EXPORTER_OTLP_COMPRESSION=gzip`. + +## Sinatra and plain Ruby + +Outside Rails, configure the SDK before your app is defined. For Sinatra, add `gem "sinatra"` as usual and put the setup at the top of the app file or in `config.ru`: + +```ruby +# app.rb +require "opentelemetry/sdk" +require "opentelemetry/exporter/otlp" +require "opentelemetry/instrumentation/all" + +OpenTelemetry::SDK.configure do |c| + c.use_all +end + +require "sinatra" + +get "/hello" do + "hello" +end +``` + +For a script or a one-off job, create your own spans and shut the provider down before exit, so the last batch is sent: + +```ruby +require "opentelemetry/sdk" +require "opentelemetry/exporter/otlp" + +OpenTelemetry::SDK.configure +at_exit { OpenTelemetry.tracer_provider.shutdown } + +tracer = OpenTelemetry.tracer_provider.tracer("nightly-import") + +tracer.in_span("import-orders") do |span| + span.set_attribute("import.source", "s3") + import_orders +end +``` + +## Sidekiq + +With the Rails initializer, Sidekiq needs no extra setup: the Sidekiq server process loads Rails and runs the initializer, and `use_all` enables the Sidekiq instrumentation. Each enqueue becomes a producer span and each job run a consumer span. + +By default the job's span starts a new trace and links back to the span that enqueued it. To make the job a child of the request that enqueued it, so both appear in one trace, set `propagation_style` to `:child`: + +```ruby +OpenTelemetry::SDK.configure do |c| + c.use_all({ + "OpenTelemetry::Instrumentation::Sidekiq" => { propagation_style: :child }, + }) +end +``` + +Long-running jobs that enqueue many others make very large traces with `:child`, so keep the default for those. + +## Custom spans + +```ruby +class ProcessOrder + TRACER = OpenTelemetry.tracer_provider.tracer("my-rails-app") + + def call(order_id) + TRACER.in_span("process-order") do |span| + span.set_attribute("order.id", order_id) + span.set_attribute("payment.method", "card") + charge_payment(order_id) + end + end +end +``` + +`in_span` records any exception raised inside the block on the span, sets its status to error and re-raises. To mark a failure you handle yourself, call `span.record_exception(e)` and `span.status = OpenTelemetry::Trace::Status.error(e.message)`. + +Service map edges come from instrumented client spans that propagate `traceparent` to an instrumented callee, not from attributes such as `peer.service`. See [Service map](/docs/explore/service-map). + +## Attributes Maple uses + +Most attributes are optional. These unlock specific features. + +### Commits and GitHub + +Set `vcs.ref.head.revision` (the full 40-character commit SHA) and `vcs.repository.url.full` on the resource. They drive deploy markers, the Releases page and, with the [GitHub integration](/docs/integrations/github), commit hover cards on traces. Read the SHA from your CI or host at build time, such as `GITHUB_SHA` or `HEROKU_SLUG_COMMIT`, not from `git` at runtime: + +```bash +export OTEL_RESOURCE_ATTRIBUTES="vcs.repository.url.full=https://github.com/acme/api,vcs.ref.head.revision=$GITHUB_SHA" +``` + +### Databases + +The `pg`, `mysql2` and `trilogy` instrumentations set the standard `db.*` attributes for you. The database system and name draw the database node on the [service map](/docs/explore/service-map), and the query text groups statements into query shapes. The `pg` and `mysql2` instrumentations obfuscate literal values in the query text by default; keep that on. See [Database queries](/docs/concepts/otel-conventions#database-queries) for the full list. + +## Logs and metrics + +The Ruby SDK's trace support is stable. Its metrics and logs SDKs (`opentelemetry-metrics-sdk`, `opentelemetry-logs-sdk` and their OTLP exporters) are still experimental, and their APIs can change between releases. This guide covers traces only. If you try the experimental exporters, they use the same endpoint and headers. + +## Verify + +1. Start your application and send it a few requests, or enqueue a Sidekiq job. +2. In Maple, open **Explore → Traces**. The SDK sends spans in batches every 5 seconds by default. +3. Each request should show up as one trace with a single root server span, named after the method and route (for example `GET /orders/:id` on Rails 7.1 and later), and child spans for the queries and outgoing calls it made. + +Your service also appears on the **Services** page once its first spans arrive. + +## Troubleshooting + +- **`401` responses.** The key is wrong, was copied from the other region, or the header is malformed. `OTEL_EXPORTER_OTLP_HEADERS` takes `Authorization=Bearer YOUR_INGEST_KEY`. See [Ingest API status codes](/docs/reference/ingest#status-codes). +- **`The grpc transport protocol is not supported` in the log.** `OTEL_EXPORTER_OTLP_PROTOCOL` is set to `grpc`, often inherited from a shared environment. Unset it or set `http/protobuf`. +- **`please add opentelemetry-exporter-otlp to your Gemfile` in the log.** The exporter gem is missing from the bundle, so spans are not exported. +- **Network.** From the machine running the app, run `curl -i https://ingest.maple.dev/v1/traces -X POST`. Any HTTP status code means the host can reach Maple. A timeout or DNS error means a firewall or proxy is blocking outbound HTTPS. +- **Nothing exported.** Set `OTEL_LOG_LEVEL=debug` to print the SDK's export errors. For a quick local check, set `OTEL_TRACES_EXPORTER=console` to print spans to stdout instead of sending them. Scripts that exit right away must call `OpenTelemetry.tracer_provider.shutdown` first. + +## Next steps + +- [Explore traces](/docs/explore/traces) +- [Track errors](/docs/errors/overview) +- [Create alert rules](/docs/alerting/alert-rules) +- [OpenTelemetry conventions](/docs/concepts/otel-conventions) diff --git a/apps/landing/src/content/docs/guides/instrumentation-rust.md b/apps/landing/src/content/docs/guides/instrumentation-rust.md index 7af06cee4c..2928b533de 100644 --- a/apps/landing/src/content/docs/guides/instrumentation-rust.md +++ b/apps/landing/src/content/docs/guides/instrumentation-rust.md @@ -301,7 +301,7 @@ Your service also appears on the **Services** page once its first spans arrive. ## Troubleshooting - **`401` responses.** The key is wrong, was copied from the other region, or the header is malformed. The header must be `Authorization: Bearer YOUR_INGEST_KEY`. In `OTEL_EXPORTER_OTLP_HEADERS` it is written `Authorization=Bearer YOUR_INGEST_KEY`. See [Ingest API status codes](/docs/reference/ingest#status-codes). -- **Wrong protocol or path.** Maple accepts OTLP over HTTP. Build exporters with `.with_http()`, not `.with_tonic()`. `with_endpoint` takes the full URL including `/v1/traces`; `OTEL_EXPORTER_OTLP_ENDPOINT` takes only the base URL. +- **Wrong path or port.** Maple accepts OTLP over HTTP and gRPC on port 443. This guide uses `.with_http()`. `.with_tonic()` also works with the base URL and no port, but needs one of the crate's TLS features. With HTTP, `with_endpoint` takes the full URL including `/v1/traces`; `OTEL_EXPORTER_OTLP_ENDPOINT` takes only the base URL. - **Network.** From the machine running the app, run `curl -i https://ingest.maple.dev/v1/traces -X POST`. Any HTTP status code means the host can reach Maple. A timeout or DNS error means a firewall or proxy is blocking outbound HTTPS. - **Nothing exported.** Shut the providers down before the process exits so buffered data is sent. If the build fails with trait errors between `tracing-opentelemetry` and `opentelemetry`, the versions do not match. diff --git a/apps/landing/src/content/docs/instrumentation.mdx b/apps/landing/src/content/docs/instrumentation.mdx index 7a89120399..1c4306e5bb 100644 --- a/apps/landing/src/content/docs/instrumentation.mdx +++ b/apps/landing/src/content/docs/instrumentation.mdx @@ -9,7 +9,7 @@ navLabel: "Overview" import GuideFilter from "../../components/docs/GuideFilter.astro" import GuideGrid from "../../components/docs/GuideGrid.astro" -Maple ingests OpenTelemetry over OTLP/HTTP, so any OTLP exporter works without a Maple-specific plugin. Each guide below installs the SDK, points it at the ingest endpoint with an ingest key from **Settings → Ingestion**, and checks that the first trace arrives. New to Maple? The [Quickstart](/docs/getting-started/quickstart) gets a first trace in about five minutes. The [maple-onboard](https://github.com/MapleTechLabs/maple/tree/main/skills/maple-onboard) skill can do the same setup from a coding agent. +Maple ingests OpenTelemetry over OTLP, on HTTP or gRPC, so any OTLP exporter works without a Maple-specific plugin. Each guide below installs the SDK, points it at the ingest endpoint with an ingest key from **Settings → Ingestion**, and checks that the first trace arrives. New to Maple? The [Quickstart](/docs/getting-started/quickstart) gets a first trace in about five minutes. The [maple-onboard](https://github.com/MapleTechLabs/maple/tree/main/skills/maple-onboard) skill can do the same setup from a coding agent. @@ -76,6 +76,8 @@ export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer YOUR_INGEST_KEY" export OTEL_SERVICE_NAME="my-service" ``` +Maple accepts OTLP over HTTP (protobuf or JSON) and OTLP over gRPC on the same host and port 443. Set `OTEL_EXPORTER_OTLP_PROTOCOL` to match the exporter package you installed: `http/protobuf` for an HTTP exporter, `grpc` for a gRPC one. + Maple reads the standard semantic conventions; the attributes it relies on for the service map, error grouping and sampling-aware throughput are listed in [OpenTelemetry conventions](/docs/concepts/otel-conventions). To develop against a local sink instead of the cloud, run [Maple Local](/docs/local-mode) and point the endpoint at `http://localhost:4318`. ## Data regions diff --git a/apps/landing/src/content/docs/integrations/gcp-opentelemetry.md b/apps/landing/src/content/docs/integrations/gcp-opentelemetry.md index af5e460515..4827b830cb 100644 --- a/apps/landing/src/content/docs/integrations/gcp-opentelemetry.md +++ b/apps/landing/src/content/docs/integrations/gcp-opentelemetry.md @@ -176,7 +176,7 @@ Limits of this setup: ## Instrumentation guides -- Languages: [Node.js](/docs/guides/instrumentation-nodejs), [Next.js](/docs/guides/instrumentation-nextjs), [Python](/docs/guides/instrumentation-python), [Go](/docs/guides/instrumentation-go), [Rust](/docs/guides/instrumentation-rust), [Java](/docs/guides/instrumentation-java), [Kotlin](/docs/guides/instrumentation-kotlin), [C# / .NET](/docs/guides/instrumentation-csharp) and [Laravel](/docs/guides/instrumentation-laravel). +- Languages: [Node.js](/docs/guides/instrumentation-nodejs), [Next.js](/docs/guides/instrumentation-nextjs), [Python](/docs/guides/instrumentation-python), [Go](/docs/guides/instrumentation-go), [Rust](/docs/guides/instrumentation-rust), [Java](/docs/guides/instrumentation-java), [Kotlin](/docs/guides/instrumentation-kotlin), [C# / .NET](/docs/guides/instrumentation-csharp), [Laravel](/docs/guides/instrumentation-laravel) and [Ruby](/docs/guides/instrumentation-ruby). - [Kubernetes infrastructure](/docs/infrastructure/kubernetes): the collector that receives OTLP from in-cluster services. - [Hosts](/docs/infrastructure/hosts): host metrics from a VM. - [Google Cloud](/docs/integrations/gcp): connect, verify and troubleshoot the connection. diff --git a/apps/landing/src/content/docs/reference/ingest.md b/apps/landing/src/content/docs/reference/ingest.md index 3805bdae7d..b41e9d2d31 100644 --- a/apps/landing/src/content/docs/reference/ingest.md +++ b/apps/landing/src/content/docs/reference/ingest.md @@ -5,12 +5,12 @@ group: "Reference" order: 3 --- -Every signal reaches Maple through one OTLP/HTTP gateway. Any OpenTelemetry SDK or Collector that can export OTLP over HTTP works without a Maple-specific exporter. +Every signal reaches Maple through one OTLP gateway. Any OpenTelemetry SDK or Collector that exports OTLP over HTTP or gRPC works without a Maple-specific exporter. | | | | ------------ | ------------------------------------------------------------------------------------- | | Base URL | `https://ingest.maple.dev`, or your [region's](/docs/reference/regions) ingest host | -| Protocol | OTLP over HTTP, `POST` | +| Protocol | OTLP over HTTP (`POST`) or OTLP over gRPC, both on port 443 | | Auth | `Authorization: Bearer maple_pk_…` (or `maple_sk_…`, or a `maple_src_…` source token) | | Encodings | Protobuf (recommended) or JSON, optionally gzip | | Max body | 20 MiB per request, measured on the compressed body; 160 MiB once decompressed | @@ -32,9 +32,17 @@ Use the ingest host of your organization's [region](/docs/reference/regions). A ```bash export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.maple.dev" export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer maple_pk_…" -export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf" +export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf" # or grpc ``` +## OTLP over gRPC + +The same host also serves the OTLP gRPC services for traces, logs and metrics (`opentelemetry.proto.collector.{trace,logs,metrics}.v1.*Service/Export`). Point a gRPC exporter at the base URL with no port, `https://ingest.maple.dev`, and it connects over TLS on port 443. Do not add port `4317`. Send the key as `authorization: Bearer maple_pk_…` metadata (or `x-maple-ingest-key`); `OTEL_EXPORTER_OTLP_HEADERS` sets it for you. Messages may be gzip-compressed. + +Failures come back as gRPC status codes instead of HTTP ones: an invalid key is `UNAUTHENTICATED` (16), a signal the source token does not allow is `PERMISSION_DENIED` (7), rate limits are `RESOURCE_EXHAUSTED` (8), a malformed request is `INVALID_ARGUMENT` (3), an unsupported `grpc-encoding` is `UNIMPLEMENTED` (12), and server-side failures are `UNAVAILABLE` (14), which exporters retry. The status message says what went wrong. + +Product events, session replays and session events are HTTP only. + ## Authentication Send your ingest key on every request, either as a Bearer token or in the `x-maple-ingest-key` header. The `Bearer` prefix is case-insensitive. diff --git a/apps/landing/src/lib/brand-marks.ts b/apps/landing/src/lib/brand-marks.ts index bf127f3331..faa1831f42 100644 --- a/apps/landing/src/lib/brand-marks.ts +++ b/apps/landing/src/lib/brand-marks.ts @@ -74,6 +74,11 @@ export const BRAND_MARKS = { hex: "#FF2D20", path: "M23.642 5.43a.364.364 0 01.014.1v5.149c0 .135-.073.26-.189.326l-4.323 2.49v4.934a.378.378 0 01-.188.326L9.93 23.949a.316.316 0 01-.066.027c-.008.002-.016.008-.024.01a.348.348 0 01-.192 0c-.011-.002-.02-.008-.03-.012-.02-.008-.042-.014-.062-.025L.533 18.755a.376.376 0 01-.189-.326V2.974c0-.033.005-.066.014-.098.003-.012.01-.02.014-.032a.369.369 0 01.023-.058c.004-.013.015-.022.023-.033l.033-.045c.012-.01.025-.018.037-.027.014-.012.027-.024.041-.034H.53L5.043.05a.375.375 0 01.375 0L9.93 2.647h.002c.015.01.027.021.04.033l.038.027c.013.014.02.03.033.045.008.011.02.021.025.033.01.02.017.038.024.058.003.011.01.021.013.032.01.031.014.064.014.098v9.652l3.76-2.164V5.527c0-.033.004-.066.013-.098.003-.01.01-.02.013-.032a.487.487 0 01.024-.059c.007-.012.018-.02.025-.033.012-.015.021-.03.033-.043.012-.012.025-.02.037-.028.014-.01.026-.023.041-.032h.001l4.513-2.598a.375.375 0 01.375 0l4.513 2.598c.016.01.027.021.042.031.012.01.025.018.036.028.013.014.022.03.034.044.008.012.019.021.024.033.011.02.018.04.024.06.006.01.012.021.015.032zm-.74 5.032V6.179l-1.578.908-2.182 1.256v4.283zm-4.51 7.75v-4.287l-2.147 1.225-6.126 3.498v4.325zM1.093 3.624v14.588l8.273 4.761v-4.325l-4.322-2.445-.002-.003H5.04c-.014-.01-.025-.021-.04-.031-.011-.01-.024-.018-.035-.027l-.001-.002c-.013-.012-.021-.025-.031-.04-.01-.011-.021-.022-.028-.036h-.002c-.008-.014-.013-.031-.02-.047-.006-.016-.014-.027-.018-.043a.49.49 0 01-.008-.057c-.002-.014-.006-.027-.006-.041V5.789l-2.18-1.257zM5.23.81L1.47 2.974l3.76 2.164 3.758-2.164zm1.956 13.505l2.182-1.256V3.624l-1.58.91-2.182 1.255v9.435zm11.581-10.95l-3.76 2.163 3.76 2.163 3.759-2.164zm-.376 4.978L16.21 7.087 14.63 6.18v4.283l2.182 1.256 1.58.908zm-8.65 9.654l5.514-3.148 2.756-1.572-3.757-2.163-4.323 2.489-3.941 2.27z", }, + ruby: { + name: "Ruby", + hex: "#CC342D", + path: "M20.156.083c3.033.525 3.893 2.598 3.829 4.77L24 4.822 22.635 22.71 4.89 23.926h.016C3.433 23.864.15 23.729 0 19.139l1.645-3 2.819 6.586.503 1.172 2.805-9.144-.03.007.016-.03 9.255 2.956-1.396-5.431-.99-3.9 8.82-.569-.615-.51L16.5 2.114 20.159.073l-.003.01zM0 19.089zM5.13 5.073c3.561-3.533 8.157-5.621 9.922-3.84 1.762 1.777-.105 6.105-3.673 9.636-3.563 3.532-8.103 5.734-9.864 3.957-1.766-1.777.045-6.217 3.612-9.75l.003-.003z", + }, csharp: { name: "C#", hex: "#512BD4", diff --git a/apps/landing/src/lib/docs-languages.ts b/apps/landing/src/lib/docs-languages.ts index d7b679e2e8..10c37829e8 100644 --- a/apps/landing/src/lib/docs-languages.ts +++ b/apps/landing/src/lib/docs-languages.ts @@ -16,6 +16,7 @@ export const LANGUAGE_IDS = [ "kotlin", "csharp", "laravel", + "ruby", ] as const export type LanguageId = (typeof LANGUAGE_IDS)[number] @@ -61,6 +62,7 @@ export const LANGUAGES: readonly Language[] = [ slug: "guides/instrumentation-laravel", hint: "PHP · Eloquent, queues", }, + { id: "ruby", name: "Ruby", slug: "guides/instrumentation-ruby", hint: "Rails, Sinatra, Sidekiq" }, ] export const languageById = (id: LanguageId): Language => { diff --git a/apps/landing/src/lib/docs-og.ts b/apps/landing/src/lib/docs-og.ts index 99d45a13d9..a10654dccc 100644 --- a/apps/landing/src/lib/docs-og.ts +++ b/apps/landing/src/lib/docs-og.ts @@ -71,6 +71,7 @@ const SDK_MARKS = { kotlin: "kotlin", csharp: "csharp", laravel: "laravel", + ruby: "ruby", } satisfies Record /** Same path as `packages/ui/src/components/icons/maple-mark.tsx`. */ diff --git a/apps/landing/src/lib/instrumentation-guides.ts b/apps/landing/src/lib/instrumentation-guides.ts index bb759e9117..f92ac3abfd 100644 --- a/apps/landing/src/lib/instrumentation-guides.ts +++ b/apps/landing/src/lib/instrumentation-guides.ts @@ -102,13 +102,14 @@ export const GUIDE_SECTIONS: readonly GuideSection[] = [ }, { id: "other", - title: "Go, Rust, .NET & PHP", + title: "Go, Rust, .NET, PHP & Ruby", chip: "Other languages", cards: [ lang("go", "otelhttp, otelgrpc, otelsql"), lang("rust", "tracing bridge · Axum, reqwest"), lang("csharp", "ASP.NET Core, HttpClient, EF Core"), lang("laravel", "PHP · Eloquent, queues, HTTP client"), + lang("ruby", "Rails, Sinatra, Sidekiq"), ], }, { diff --git a/apps/landing/src/lib/integrations-hub.ts b/apps/landing/src/lib/integrations-hub.ts index 05c582dd92..b1cc601f9d 100644 --- a/apps/landing/src/lib/integrations-hub.ts +++ b/apps/landing/src/lib/integrations-hub.ts @@ -112,6 +112,13 @@ const languages: HubTile[] = [ href: doc("guides/instrumentation-laravel"), icon: { lang: "laravel" }, }, + { + name: "Ruby", + keywords: "rails sinatra sidekiq", + hint: "Rails, Sinatra, Sidekiq", + href: doc("guides/instrumentation-ruby"), + icon: { lang: "ruby" }, + }, { name: "Cloudflare Workers", hint: m.intg_hint_workers, diff --git a/apps/web/src/lib/docs.ts b/apps/web/src/lib/docs.ts index 3c23ec2db4..279e61f939 100644 --- a/apps/web/src/lib/docs.ts +++ b/apps/web/src/lib/docs.ts @@ -14,6 +14,7 @@ export const DOCS = { kotlinGuide: "/docs/guides/instrumentation-kotlin", dotnetGuide: "/docs/guides/instrumentation-csharp", laravelGuide: "/docs/guides/instrumentation-laravel", + rubyGuide: "/docs/guides/instrumentation-ruby", effectSdk: "/docs/sdks/effect", quickstart: "/docs/getting-started/quickstart", otelConventions: "/docs/concepts/otel-conventions", From abf371b5a206374310901ef84acc2504944dfa4d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 15:45:15 +0100 Subject: [PATCH 2/9] fix(onboarding): setup snippets match the docs, the browser card gets the browser SDK The Browser stack card opened the Next.js server snippet filled with the private key. It now shows MapleBrowser.init with the public key. Every in-app snippet now follows the docs quickstart: the key lives in the environment, not in a source file; Node, Python and Java use their zero-code agents; Go wraps the HTTP handler with otelhttp. Node no longer imports a package it does not install. Java/Kotlin, .NET, Laravel, Ruby, Rust and Deno get their own setup instead of "see opentelemetry.io". --- .../components/quick-start/sdk-snippets.ts | 428 ++++++++++++------ apps/web/src/lib/onboarding-stack.test.ts | 23 +- apps/web/src/lib/onboarding-stack.ts | 16 +- apps/web/src/page/kit/guided-setup.test.ts | 81 ++++ apps/web/src/page/kit/guided-setup.ts | 109 ++++- .../quick-start-agent-prompt.scene.test.ts | 5 +- .../page/onboarding/quick-start-connect.ts | 12 +- .../src/page/onboarding/quick-start-view.ts | 7 +- .../src/page/overview/setup-checklist-view.ts | 2 +- .../src/page/settings/ingestion/view-setup.ts | 4 +- apps/web/src/page/settings/ingestion/view.ts | 14 +- apps/web/src/page/sources/kit/snippets.ts | 11 +- 12 files changed, 501 insertions(+), 211 deletions(-) create mode 100644 apps/web/src/page/kit/guided-setup.test.ts diff --git a/apps/web/src/components/quick-start/sdk-snippets.ts b/apps/web/src/components/quick-start/sdk-snippets.ts index 074d60ba5f..8a8d6647ca 100644 --- a/apps/web/src/components/quick-start/sdk-snippets.ts +++ b/apps/web/src/components/quick-start/sdk-snippets.ts @@ -1,211 +1,341 @@ import type { Lang } from "@maple/views/lib/highlight" +import { String as Str } from "effect" -export type FrameworkId = "nextjs" | "nodejs" | "python" | "go" | "effect" | "otel" +export type FrameworkId = + | "nodejs" + | "nextjs" + | "browser" + | "python" + | "go" + | "java" + | "dotnet" + | "laravel" + | "ruby" + | "rust" + | "deno" + | "effect" + | "otel" + +/** A block of the setup: its file or shell, and what it holds. `{{INGEST_URL}}` and `{{API_KEY}}` are filled in. */ +export type SnippetBlock = Readonly<{ label: string; lang: Lang; code: string }> export interface SdkSnippet { language: FrameworkId label: string - /** The language `instrument` is written in. */ - lang: Lang description: string - iconKey: FrameworkId - install: string | { packages: string[] } - instrument: string + /** The key the snippet carries: the public one for code that ships to browsers, else the private one. */ + key: "private" | "public" + /** A shell command, or npm packages shown per package manager. */ + install: string | { packages: ReadonlyArray } + /** Where the endpoint and key go: the shell or `.env` the app starts from. Null when `code` holds them. */ + env: SnippetBlock | null + /** What starts sending: a run command or the few lines of code. */ + code: SnippetBlock } -// Every snippet configures a server-side sender, so `{{API_KEY}}` is the private ingest key. -export const sdkSnippets: SdkSnippet[] = [ +// The same setups as the docs quickstart: the key lives in the environment, never in a source file, +// and runtimes with a zero-code agent use it, so every request is traced, not one test span. + +const otlpEnv = (serviceName: string): SnippetBlock => ({ + label: "Environment", + lang: "bash", + code: `export OTEL_EXPORTER_OTLP_ENDPOINT="{{INGEST_URL}}" +export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer {{API_KEY}}" +export OTEL_SERVICE_NAME="${serviceName}"`, +}) + +export const sdkSnippets: ReadonlyArray = [ + { + language: "nodejs", + label: "Node.js", + description: "Express, Fastify, Nest", + key: "private", + install: { packages: ["@opentelemetry/api", "@opentelemetry/auto-instrumentations-node"] }, + env: otlpEnv("my-service"), + code: { + label: "Run", + lang: "bash", + code: `# Start your app through the register hook. No code changes. +node --require @opentelemetry/auto-instrumentations-node/register app.js`, + }, + }, { language: "nextjs", - lang: "ts", label: "Next.js", - description: "React framework", - iconKey: "nextjs", - install: { - packages: ["@vercel/otel", "@opentelemetry/sdk-logs", "@opentelemetry/exporter-logs-otlp-http"], - }, - instrument: `// instrumentation.ts (project root) + description: "App Router", + key: "private", + install: { packages: ["@vercel/otel", "@opentelemetry/api"] }, + env: otlpEnv("my-next-app"), + code: { + label: "instrumentation.ts", + lang: "ts", + code: `// instrumentation.ts (project root, or src/ if your code lives there) import { registerOTel } from "@vercel/otel"; -import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"; -import { SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs"; +// Reads the endpoint and key from the environment. export function register() { - registerOTel({ - serviceName: "my-next-app", - attributes: { environment: "production" }, - traceExporter: { - url: "{{INGEST_URL}}/v1/traces", - headers: { Authorization: "Bearer {{API_KEY}}" }, - }, - logRecordProcessor: new SimpleLogRecordProcessor( - new OTLPLogExporter({ - url: "{{INGEST_URL}}/v1/logs", - headers: { Authorization: "Bearer {{API_KEY}}" }, - }) - ), - }); + registerOTel({ serviceName: "my-next-app" }); }`, + }, }, { - language: "nodejs", - lang: "ts", - label: "Node.js", - description: "JavaScript runtime", - iconKey: "nodejs", - install: { - packages: [ - "@opentelemetry/sdk-node", - "@opentelemetry/auto-instrumentations-node", - "@opentelemetry/exporter-trace-otlp-http", - "@opentelemetry/exporter-logs-otlp-http", - ], - }, - instrument: `// tracing.ts (run with: node --import ./tracing.ts app.ts) -import { NodeSDK } from "@opentelemetry/sdk-node"; -import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node"; -import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; -import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"; -import { SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs"; - -const sdk = new NodeSDK({ - // Without this your service shows up as "unknown_service:node". - serviceName: "my-service", - traceExporter: new OTLPTraceExporter({ - url: "{{INGEST_URL}}/v1/traces", - headers: { Authorization: "Bearer {{API_KEY}}" }, - }), - logRecordProcessors: [ - new SimpleLogRecordProcessor( - new OTLPLogExporter({ - url: "{{INGEST_URL}}/v1/logs", - headers: { Authorization: "Bearer {{API_KEY}}" }, - }) - ), - ], - instrumentations: [getNodeAutoInstrumentations()], -}); - -sdk.start();`, + language: "browser", + label: "Browser", + description: "React, Vue, any web app", + key: "public", + install: { packages: ["@maple-dev/browser"] }, + env: null, + code: { + label: "main.ts", + lang: "ts", + code: `// The public key is safe to ship in your bundle: it can only send data. +import { MapleBrowser } from "@maple-dev/browser"; + +MapleBrowser.init({ + ingestKey: "{{API_KEY}}", + endpoint: "{{INGEST_URL}}", + serviceName: "my-web-app", +});`, + }, }, { language: "python", - lang: "python", label: "Python", - description: "General purpose", - iconKey: "python", - install: `pip install opentelemetry-sdk \\ - opentelemetry-exporter-otlp-proto-http \\ - opentelemetry-instrumentation`, - instrument: `# tracing.py -from opentelemetry import trace -from opentelemetry.sdk.resources import SERVICE_NAME, Resource -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor -from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter - -# Without a service name your service shows up as "unknown_service". -provider = TracerProvider(resource=Resource.create({SERVICE_NAME: "my-service"})) -exporter = OTLPSpanExporter( - endpoint="{{INGEST_URL}}/v1/traces", - headers={"Authorization": "Bearer {{API_KEY}}"}, -) -provider.add_span_processor(BatchSpanProcessor(exporter)) -trace.set_tracer_provider(provider) - -# Create a tracer and send a test span -tracer = trace.get_tracer("quickstart") -with tracer.start_as_current_span("hello-maple"): - print("Trace sent!")`, + description: "Django, FastAPI, Flask", + key: "private", + install: `pip install opentelemetry-distro opentelemetry-exporter-otlp-proto-http +opentelemetry-bootstrap -a install`, + env: { + ...otlpEnv("my-service"), + code: `${otlpEnv("my-service").code} +export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"`, + }, + code: { + label: "Run", + lang: "bash", + code: `# Start your app through the wrapper. No code changes. +opentelemetry-instrument python app.py +# or: opentelemetry-instrument uvicorn main:app`, + }, }, { language: "go", - lang: "go", label: "Go", - description: "Systems language", - iconKey: "go", + description: "net/http services", + key: "private", install: `go get go.opentelemetry.io/otel \\ go.opentelemetry.io/otel/sdk \\ - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp`, - instrument: `package main + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp \\ + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp`, + env: otlpEnv("my-service"), + code: { + label: "main.go", + lang: "go", + code: `package main import ( "context" "log" + "net/http" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" - "go.opentelemetry.io/otel/sdk/resource" - "go.opentelemetry.io/otel/sdk/trace" - semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + sdktrace "go.opentelemetry.io/otel/sdk/trace" ) func main() { ctx := context.Background() - exporter, err := otlptracehttp.New(ctx, - otlptracehttp.WithEndpointURL("{{INGEST_URL}}/v1/traces"), - otlptracehttp.WithHeaders(map[string]string{ - "Authorization": "Bearer {{API_KEY}}", - }), - ) - if err != nil { - log.Fatal(err) + // Reads the endpoint and key from the environment. + if exporter, err := otlptracehttp.New(ctx); err != nil { + log.Printf("telemetry disabled: %v", err) + } else { + tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter)) + defer tp.Shutdown(ctx) + otel.SetTracerProvider(tp) } - // Without a service name your service shows up as "unknown_service". - res := resource.NewWithAttributes(semconv.SchemaURL, semconv.ServiceName("my-service")) - tp := trace.NewTracerProvider(trace.WithBatcher(exporter), trace.WithResource(res)) - defer tp.Shutdown(ctx) - otel.SetTracerProvider(tp) + mux := http.NewServeMux() + mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("hello")) + }) + // Every request through this handler is traced. + log.Fatal(http.ListenAndServe(":8080", otelhttp.NewHandler(mux, "server"))) +}`, + }, + }, + { + language: "java", + label: "Java", + description: "Spring, Kotlin, any JVM app", + key: "private", + install: `curl -L -o opentelemetry-javaagent.jar \\ + https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar`, + env: otlpEnv("my-service"), + code: { + label: "Run", + lang: "bash", + code: `# Attach the agent when you start the app. No code changes. +java -javaagent:opentelemetry-javaagent.jar -jar app.jar`, + }, + }, + { + language: "dotnet", + label: ".NET", + description: "ASP.NET Core", + key: "private", + install: `dotnet add package OpenTelemetry.Extensions.Hosting +dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol +dotnet add package OpenTelemetry.Instrumentation.AspNetCore`, + env: otlpEnv("my-service"), + code: { + label: "Program.cs", + lang: "csharp", + code: `using OpenTelemetry; +using OpenTelemetry.Trace; - // Send a test span - tracer := otel.Tracer("quickstart") - _, span := tracer.Start(ctx, "hello-maple") - span.End() - tp.ForceFlush(ctx) +var builder = WebApplication.CreateBuilder(args); - log.Println("Trace sent!") +// Reads the endpoint, key and service name from the environment. +builder.Services.AddOpenTelemetry() + .WithTracing(tracing => tracing.AddAspNetCoreInstrumentation()) + .UseOtlpExporter(); + +var app = builder.Build(); +app.MapGet("/", () => "Hello!"); +app.Run();`, + }, + }, + { + language: "laravel", + label: "Laravel", + description: "PHP", + key: "private", + install: "composer require keepsuit/laravel-opentelemetry", + env: { + label: ".env", + lang: "bash", + code: `OTEL_SERVICE_NAME=my-service +OTEL_EXPORTER_OTLP_ENDPOINT={{INGEST_URL}} +OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer {{API_KEY}}"`, + }, + code: { + label: "Run", + lang: "bash", + code: `# Laravel reads .env, so there is nothing else to add. Start the app and send it a request. +php artisan serve`, + }, + }, + { + language: "ruby", + label: "Ruby", + description: "Rails, Sinatra", + key: "private", + install: "bundle add opentelemetry-sdk opentelemetry-exporter-otlp opentelemetry-instrumentation-all", + env: otlpEnv("my-service"), + code: { + label: "config/initializers/opentelemetry.rb", + lang: "ruby", + code: `require "opentelemetry/sdk" +require "opentelemetry/exporter/otlp" +require "opentelemetry/instrumentation/all" + +# Reads the endpoint, key and service name from the environment, +# and traces Rails, Rack, ActiveRecord, Net::HTTP and Sidekiq. +OpenTelemetry::SDK.configure(&:use_all)`, + }, + }, + { + language: "rust", + label: "Rust", + description: "Axum, Tokio", + key: "private", + install: `cargo add opentelemetry opentelemetry_sdk opentelemetry-otlp tracing tracing-subscriber +cargo add tracing-opentelemetry@0.32`, + env: otlpEnv("my-service"), + code: { + label: "src/main.rs", + lang: "rust", + code: `use opentelemetry::trace::TracerProvider as _; +use opentelemetry_sdk::trace::SdkTracerProvider; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +fn main() -> Result<(), Box> { + // Reads the endpoint, key and service name from the environment. + let exporter = opentelemetry_otlp::SpanExporter::builder().with_http().build()?; + let provider = SdkTracerProvider::builder().with_batch_exporter(exporter).build(); + tracing_subscriber::registry() + .with(tracing_opentelemetry::layer().with_tracer(provider.tracer("app"))) + .with(tracing_subscriber::fmt::layer()) + .init(); + + // Your app: every #[tracing::instrument] function becomes a span. + + provider.shutdown()?; + Ok(()) }`, + }, + }, + { + language: "deno", + label: "Deno", + description: "Built-in OpenTelemetry", + key: "private", + install: "# Nothing to install: Deno 2.2+ has OpenTelemetry built in.", + env: otlpEnv("my-service"), + code: { + label: "Run", + lang: "bash", + code: `# Deno.serve and fetch are traced, console.log becomes logs. +OTEL_DENO=true deno run --allow-net --allow-env main.ts`, + }, }, { language: "effect", - lang: "ts", label: "Effect", - description: "TypeScript toolkit (Effect 3: use @effect-v3 tag)", - iconKey: "effect", + description: "Maple's own SDK (Effect 3: the @effect-v3 tag)", + key: "private", install: { packages: ["@maple-dev/effect-sdk", "effect"] }, - instrument: `// telemetry.ts -import { Maple } from "@maple-dev/effect-sdk" + env: { + label: "Environment", + lang: "bash", + code: `export MAPLE_ENDPOINT="{{INGEST_URL}}" +export MAPLE_INGEST_KEY="{{API_KEY}}"`, + }, + code: { + label: "telemetry.ts", + lang: "ts", + code: `import { Maple } from "@maple-dev/effect-sdk" import { Effect } from "effect" -// Auto-detects MAPLE_ENDPOINT, MAPLE_INGEST_KEY, -// commit SHA, and deployment environment from env vars -const TracerLive = Maple.layer({ - serviceName: "my-effect-app", -}) +// Reads MAPLE_ENDPOINT and MAPLE_INGEST_KEY, plus the commit and environment. +const TracerLive = Maple.layer({ serviceName: "my-effect-app" }) -// Use in your program const program = Effect.gen(function* () { yield* Effect.log("Hello from Effect!") }).pipe(Effect.withSpan("hello-maple")) -Effect.runPromise( - program.pipe(Effect.provide(TracerLive)) -)`, +Effect.runPromise(program.pipe(Effect.provide(TracerLive)))`, + }, }, { language: "otel", - lang: "bash", - label: "Custom / OpenTelemetry", - description: "Any language or runtime. Point your OTLP exporter at Maple", - iconKey: "otel", - install: `# Use your language's OpenTelemetry SDK -# See https://opentelemetry.io/docs/languages/ for installation`, - instrument: `# Configure via environment variables -export OTEL_EXPORTER_OTLP_ENDPOINT="{{INGEST_URL}}" -export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer {{API_KEY}}" -export OTEL_SERVICE_NAME="my-service" - -# Then run your application with your language's OTel SDK enabled`, + label: "Other", + description: "Any OpenTelemetry SDK", + key: "private", + install: `# Add your language's OpenTelemetry SDK: https://opentelemetry.io/docs/languages/`, + env: otlpEnv("my-service"), + code: { + label: "Run", + lang: "bash", + code: `# Every OpenTelemetry SDK reads the variables above. +# Start your app from the same shell.`, + }, }, ] + +/** The snippet's text with the endpoint and key filled in. */ +export const fillSnippet = (template: string, ingestUrl: string, apiKey: string): string => + Str.replaceAll("{{API_KEY}}", apiKey)(Str.replaceAll("{{INGEST_URL}}", ingestUrl)(template)) diff --git a/apps/web/src/lib/onboarding-stack.test.ts b/apps/web/src/lib/onboarding-stack.test.ts index d6a326ff10..50a8547ca0 100644 --- a/apps/web/src/lib/onboarding-stack.test.ts +++ b/apps/web/src/lib/onboarding-stack.test.ts @@ -6,6 +6,8 @@ import { intentsOf, languageDocsOf, searchLanguages, + STACK_LANGUAGE_IDS, + type StackLanguage, toggled, } from "./onboarding-stack" @@ -41,13 +43,20 @@ describe("onboarding stack", () => { expect(frameworkOf({ languages: [], platforms: ["docker"] })).toBe("otel") }) - test("a language without its own snippet opens on OpenTelemetry and links the overview guide", () => { - const ruby = { languages: ["ruby"], platforms: [] } as const - expect(frameworkOf(ruby)).toBe("otel") - expect(languageDocsOf(ruby)?.page).toBe("instrumentation") - // Bun is not Node: the Node snippet starts the app with `node --import`. - expect(frameworkOf({ languages: ["bun"], platforms: [] })).toBe("otel") - expect(frameworkOf({ languages: ["effect"], platforms: [] })).toBe("effect") + test("each language opens the setup it documents, and only Bun falls back to OpenTelemetry", () => { + const opens = (language: StackLanguage) => frameworkOf({ languages: [language], platforms: [] }) + expect(opens("browser")).toBe("browser") + expect(opens("kotlin")).toBe("java") + expect(opens("php")).toBe("laravel") + expect(opens("ruby")).toBe("ruby") + expect(opens("deno")).toBe("deno") + // Bun is not Node: the Node setup starts the app with `node --require`. + expect(opens("bun")).toBe("otel") + expect(languageDocsOf({ languages: ["php"], platforms: [] })?.page).toBe("laravelGuide") + expect(Arr.filter(STACK_LANGUAGE_IDS, (language) => opens(language) === "otel")).toEqual([ + "bun", + "otel", + ]) }) test("language search matches names, frameworks and aliases", () => { diff --git a/apps/web/src/lib/onboarding-stack.ts b/apps/web/src/lib/onboarding-stack.ts index 64b2d4606a..b959c344a7 100644 --- a/apps/web/src/lib/onboarding-stack.ts +++ b/apps/web/src/lib/onboarding-stack.ts @@ -226,18 +226,18 @@ export const intentsOf = (answers: StackAnswers): ReadonlyArray diff --git a/apps/web/src/page/kit/guided-setup.test.ts b/apps/web/src/page/kit/guided-setup.test.ts new file mode 100644 index 0000000000..32235b3a01 --- /dev/null +++ b/apps/web/src/page/kit/guided-setup.test.ts @@ -0,0 +1,81 @@ +import { Array as Arr, Option } from "effect" +import { describe, expect, test } from "vitest" +import { type FrameworkId, sdkSnippets } from "../../components/quick-start/sdk-snippets" +import { NO_KEYS, serverKey, type SetupKeys, setupBlocks } from "./guided-setup" + +const KEYS = { privateKey: "maple_sk_secret", publicKey: "maple_pk_public" } + +const codeOf = (framework: FrameworkId, keys: SetupKeys = KEYS): string => + Arr.join( + Arr.map(setupBlocks(framework, keys), (block) => block.code), + "\n", + ) + +const installOf = (framework: FrameworkId): string => { + const install = Option.getOrThrow( + Arr.findFirst(sdkSnippets, (entry) => entry.language === framework), + ).install + return typeof install === "string" ? install : Arr.join(install.packages, " ") +} + +describe("setup snippets", () => { + test("the browser setup is the browser SDK with the public key, never the private one", () => { + const code = codeOf("browser") + expect(code).toContain("MapleBrowser.init") + expect(code).toContain('ingestKey: "maple_pk_public"') + expect(code).not.toContain("maple_sk_secret") + expect(code).not.toContain("@vercel/otel") + }) + + test("server setups keep the private key in the environment, out of the source file", () => { + for (const framework of [ + "nodejs", + "nextjs", + "python", + "go", + "java", + "dotnet", + "ruby", + "rust", + ] as const) { + const [env, source] = setupBlocks(framework, KEYS) + expect(env?.code).toContain("maple_sk_secret") + expect(source?.code).not.toContain("maple_sk_secret") + } + }) + + test("runtimes with an agent trace every request instead of sending one test span", () => { + expect(codeOf("nodejs")).toContain("--require @opentelemetry/auto-instrumentations-node/register") + expect(codeOf("python")).toContain("opentelemetry-instrument python app.py") + expect(codeOf("go")).toContain("otelhttp.NewHandler") + expect(codeOf("java")).toContain("-javaagent:opentelemetry-javaagent.jar") + for (const framework of ["nodejs", "python", "go"] as const) + expect(codeOf(framework)).not.toContain("hello-maple") + }) + + test("Python sends over HTTP, matching the exporter package it installs", () => { + expect(installOf("python")).toContain("opentelemetry-exporter-otlp-proto-http") + expect(codeOf("python")).toContain('OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"') + }) + + test("the Node install has every package the setup needs", () => { + expect(installOf("nodejs")).toBe("@opentelemetry/api @opentelemetry/auto-instrumentations-node") + expect(codeOf("nodejs")).not.toContain("@opentelemetry/sdk-logs") + }) + + test("Ruby gets a Rails initializer, not a pointer to opentelemetry.io", () => { + expect(installOf("ruby")).toContain("bundle add opentelemetry-sdk") + expect(codeOf("ruby")).toContain("OpenTelemetry::SDK.configure(&:use_all)") + }) + + test("someone who can read only the public key gets it in server setups too", () => { + const memberKeys = { privateKey: null, publicKey: "maple_pk_public" } + expect(codeOf("python", memberKeys)).toContain("Authorization=Bearer maple_pk_public") + expect(serverKey(memberKeys)).toBe("maple_pk_public") + expect(serverKey(KEYS)).toBe("maple_sk_secret") + }) + + test("without a key the setup shows a placeholder", () => { + expect(codeOf("nodejs", NO_KEYS)).toContain("Authorization=Bearer ") + }) +}) diff --git a/apps/web/src/page/kit/guided-setup.ts b/apps/web/src/page/kit/guided-setup.ts index d1ed3b53f2..d988311189 100644 --- a/apps/web/src/page/kit/guided-setup.ts +++ b/apps/web/src/page/kit/guided-setup.ts @@ -4,7 +4,13 @@ import { ONBOARD_SKILL_COMMAND, onboardSkillPrompt, } from "../../components/ingest/onboard-skill" -import { type FrameworkId, sdkSnippets } from "../../components/quick-start/sdk-snippets" +import { + type FrameworkId, + fillSnippet, + type SdkSnippet, + type SnippetBlock, + sdkSnippets, +} from "../../components/quick-start/sdk-snippets" import { type DocsPage, docsUrl } from "../../lib/docs" import type { Lang } from "@maple/views/lib/highlight" import { ingestUrl } from "../../lib/services/common/ingest-url" @@ -19,29 +25,83 @@ import { panel } from "@maple/views/ui/panel" import type * as Tabs from "@maple/views/ui/tabs" import * as TabsView from "@maple/views/ui/tabs-view" import { codeBlock } from "../settings/kit/code-block" -import { Array as Arr, String as Str, Option } from "effect" +import { Array as Arr, Option } from "effect" // `FrameworkIconStrip`, `FrameworkPicker` and `ConnectInstructions`, with `PackageManagerCodeBlock`. const FRAMEWORK_ICONS = { - nextjs: "FrameworkNextjsIcon", nodejs: "FrameworkNodejsIcon", + nextjs: "FrameworkNextjsIcon", + browser: "GlobeIcon", python: "FrameworkPythonIcon", go: "FrameworkGoIcon", + java: "OpenjdkIcon", + dotnet: "CsharpIcon", + laravel: "LaravelIcon", + ruby: "RubyIcon", + rust: "RustIcon", + deno: "DenoIcon", effect: "FrameworkEffectIcon", otel: "FrameworkOpenTelemetryIcon", } as const satisfies { readonly [Id in FrameworkId]: IconName } /** The guide each snippet's Instrument tab links to. */ const FRAMEWORK_DOCS = { - nextjs: { label: "Next.js", page: "nextjsGuide" }, nodejs: { label: "Node.js", page: "nodejsGuide" }, + nextjs: { label: "Next.js", page: "nextjsGuide" }, + browser: { label: "Browser SDK", page: "browserSdk" }, python: { label: "Python", page: "pythonGuide" }, go: { label: "Go", page: "goGuide" }, + java: { label: "Java", page: "javaGuide" }, + dotnet: { label: ".NET", page: "dotnetGuide" }, + laravel: { label: "Laravel", page: "laravelGuide" }, + ruby: { label: "OpenTelemetry", page: "instrumentation" }, + rust: { label: "Rust", page: "rustGuide" }, + deno: { label: "OpenTelemetry", page: "instrumentation" }, effect: { label: "Effect", page: "effectSdk" }, otel: { label: "OpenTelemetry", page: "instrumentation" }, } as const satisfies { readonly [Id in FrameworkId]: InstructionsDocs } +/** The org's ingest keys as far as the viewer can read them; `null` where a key could not be read. */ +export type SetupKeys = Readonly<{ privateKey: string | null; publicKey: string | null }> + +export const NO_KEYS: SetupKeys = { privateKey: null, publicKey: null } + +/** + * The key a snippet carries: browser code gets the public key; server code the private one, or the + * public one when the viewer cannot read the private key (it sends from servers too). + */ +export const keyFor = (snippet: Pick, keys: SetupKeys): string | null => + snippet.key === "public" ? keys.publicKey : (keys.privateKey ?? keys.publicKey) + +/** One code block of the Instrument tab, filled in. */ +export type SetupBlock = Readonly<{ id: "env" | "instrument"; label: string; lang: Lang; code: string }> + +/** The Instrument tab's blocks for `framework`: where the key goes, then what starts sending. */ +export const setupBlocks = (framework: string, keys: SetupKeys): ReadonlyArray => + Option.match( + Arr.findFirst(sdkSnippets, (entry) => entry.language === framework), + { + onNone: () => [], + onSome: (snippet) => { + const key = keyFor(snippet, keys) ?? "" + const fill = (block: SnippetBlock, id: SetupBlock["id"]): SetupBlock => ({ + id, + label: block.label, + lang: block.lang, + code: fillSnippet(block.code, ingestUrl, key), + }) + return [ + ...(snippet.env === null ? [] : [fill(snippet.env, "env")]), + fill(snippet.code, "instrument"), + ] + }, + }, + ) + +/** The key the coding-agent prompt carries: the one a server snippet gets. */ +export const serverKey = (keys: SetupKeys): string | null => keyFor({ key: "private" }, keys) + const SETUP_TAB_ICONS = { install: "DownloadIcon", instrument: "CodeIcon", @@ -64,7 +124,9 @@ export const PACKAGE_TAB_VALUES = Arr.map(PACKAGE_MANAGERS, (pm) => pm.id) export const frameworkIconStrip = (h: HtmlBuilder): Html => h.span( [h.Class("text-muted-foreground flex shrink-0 items-center gap-2"), h.AriaHidden(true)], - Arr.map(sdkSnippets, (snippet) => icon(h, FRAMEWORK_ICONS[snippet.language], { size: 14 })), + Arr.map(Arr.take(sdkSnippets, 6), (snippet) => + icon(h, FRAMEWORK_ICONS[snippet.language], { size: 14 }), + ), ) /** `FrameworkPicker`: pill chips (`compact`) or labelled cards under "Pick your stack". */ @@ -128,7 +190,7 @@ const AGENT_LABELS = { export const agentPromptButton = ( h: HtmlBuilder, options: Readonly<{ - apiKey: string + apiKey: string | null copy: CopyButton.Model toCopyMessage: (message: CopyButton.Message) => M }>, @@ -144,7 +206,9 @@ export const agentPromptButton = ( h.Title("Copy a setup prompt for Claude Code, Codex or Cursor"), h.OnClick( options.toCopyMessage( - CopyButton.Message.ClickedCopy({ text: agentSetupPrompt(ingestUrl, options.apiKey) }), + CopyButton.Message.ClickedCopy({ + text: agentSetupPrompt(ingestUrl, options.apiKey ?? ""), + }), ), ), ], @@ -206,7 +270,7 @@ export type InstructionsParts = Readonly<{ /** Slot id prefix, unique per page (`ingestion`, `overview-setup`). */ slot: string framework: string - apiKey: string + keys: SetupKeys setupTabs: Tabs.Model packageTabs: Tabs.Model snippetCopies: Readonly> @@ -278,12 +342,7 @@ export const connectInstructions = (h: HtmlBuilder, parts: InstructionsPar const snippet = found.value const isBoxed = parts.variant === "boxed" const padding = isBoxed ? "p-3" : "px-4 pt-3 pb-4" - const apiKey = parts.apiKey - const interpolate = (template: string) => - Str.replace( - /\{\{API_KEY\}\}/g, - apiKey || "", - )(Str.replace(/\{\{INGEST_URL\}\}/g, ingestUrl)(template)) + const blocks = setupBlocks(snippet.language, parts.keys) const install = snippet.install const docs = parts.docs ?? FRAMEWORK_DOCS[snippet.language] const tabs = h.submodel({ @@ -336,15 +395,17 @@ export const connectInstructions = (h: HtmlBuilder, parts: InstructionsPar ] if (value === "instrument") return [ - block( - h, - parts, - "instrument", - interpolate(snippet.instrument), - Str.toLowerCase(snippet.label), - REPLAY_BLOCK_CLASS, - false, - snippet.lang, + ...Arr.map(blocks, (entry, index) => + block( + h, + parts, + entry.id, + entry.code, + entry.label, + cn(REPLAY_BLOCK_CLASS, index < blocks.length - 1 && "mb-3"), + false, + entry.lang, + ), ), h.a( [ @@ -385,7 +446,7 @@ export const connectInstructions = (h: HtmlBuilder, parts: InstructionsPar h, parts, "skill-prompt", - onboardSkillPrompt(ingestUrl, apiKey), + onboardSkillPrompt(ingestUrl, serverKey(parts.keys) ?? ""), "prompt", REPLAY_BLOCK_CLASS, true, diff --git a/apps/web/src/page/onboarding/quick-start-agent-prompt.scene.test.ts b/apps/web/src/page/onboarding/quick-start-agent-prompt.scene.test.ts index c78e63aaa1..2a305b7821 100644 --- a/apps/web/src/page/onboarding/quick-start-agent-prompt.scene.test.ts +++ b/apps/web/src/page/onboarding/quick-start-agent-prompt.scene.test.ts @@ -22,7 +22,10 @@ const atConnect = (): Model => { return { ...model, state, - connect: { ...model.connect, keys: { _tag: "Ready", privateKey: "maple_sk_test" } }, + connect: { + ...model.connect, + keys: { _tag: "Ready", privateKey: "maple_sk_test", publicKey: "maple_pk_test" }, + }, } } diff --git a/apps/web/src/page/onboarding/quick-start-connect.ts b/apps/web/src/page/onboarding/quick-start-connect.ts index 51798c579e..e9fba4f9a7 100644 --- a/apps/web/src/page/onboarding/quick-start-connect.ts +++ b/apps/web/src/page/onboarding/quick-start-connect.ts @@ -27,7 +27,7 @@ const isStatusProvider = (platform: StackPlatform): platform is StatusProvider = const Keys = Schema.Union([ Schema.TaggedStruct("Pending", {}), - Schema.TaggedStruct("Ready", { privateKey: Schema.String }), + Schema.TaggedStruct("Ready", { privateKey: Schema.String, publicKey: Schema.String }), /** The read failed (not an admin, or the key store is down): the prompt shows a placeholder. */ Schema.TaggedStruct("Failed", {}), ]) @@ -45,7 +45,7 @@ export const Connect = Schema.Struct({ export type Connect = typeof Connect.Type export const ConnectMessage = defineMessageUnion({ - SucceededLoadKeys: { privateKey: Schema.String }, + SucceededLoadKeys: { privateKey: Schema.String, publicKey: Schema.String }, FailedLoadKeys: {}, LoadedPresence: { result: Schema.Result(SignalPresences, LoadFailure) }, LoadedConnected: { connected: Schema.Record(Schema.String, Schema.Boolean) }, @@ -73,7 +73,9 @@ export const initConnect = (): Connect => ({ const FetchConnectKeys = Command.define("FetchOnboardingConnectKeys", { messages: [ConnectMessage.SucceededLoadKeys, ConnectMessage.FailedLoadKeys], execute: Effect.flatMap(MapleApiV2Client, (client) => client.ingestKeys.retrieve()).pipe( - Effect.map((keys) => ConnectMessage.SucceededLoadKeys({ privateKey: keys.private_key })), + Effect.map((keys) => + ConnectMessage.SucceededLoadKeys({ privateKey: keys.private_key, publicKey: keys.public_key }), + ), Effect.orElseSucceed(() => ConnectMessage.FailedLoadKeys()), ), }) @@ -174,8 +176,8 @@ export const updateConnect = ( platforms: ReadonlyArray, ): Step => ConnectMessage.match(message, { - SucceededLoadKeys: ({ privateKey }) => ({ - connect: { ...connect, keys: { _tag: "Ready", privateKey } }, + SucceededLoadKeys: ({ privateKey, publicKey }) => ({ + connect: { ...connect, keys: { _tag: "Ready", privateKey, publicKey } }, commands: [], }), FailedLoadKeys: () => ({ connect: { ...connect, keys: { _tag: "Failed" } }, commands: [] }), diff --git a/apps/web/src/page/onboarding/quick-start-view.ts b/apps/web/src/page/onboarding/quick-start-view.ts index 918ee486a8..2604421680 100644 --- a/apps/web/src/page/onboarding/quick-start-view.ts +++ b/apps/web/src/page/onboarding/quick-start-view.ts @@ -584,6 +584,9 @@ const providerRow = (h: H, model: Model, platform: StackPlatform): Html => { ) } +const keysOf = (connect: Model["connect"]): GuidedSetup.SetupKeys => + connect.keys._tag === "Ready" ? connect.keys : GuidedSetup.NO_KEYS + export const connectStep = (h: H, model: Model, state: QuickStartState): Html => { const { platforms } = state.stack const { connect } = model @@ -649,7 +652,7 @@ export const connectStep = (h: H, model: Model, state: QuickStartState): Html => ), ]), GuidedSetup.agentPromptButton(h, { - apiKey: connect.keys._tag === "Ready" ? connect.keys.privateKey : "", + apiKey: GuidedSetup.serverKey(keysOf(connect)), copy: connect.snippetCopies[AGENT_PROMPT_COPY] ?? CopyButton.init(), toCopyMessage: (message) => toConnect( @@ -659,7 +662,7 @@ export const connectStep = (h: H, model: Model, state: QuickStartState): Html => GuidedSetup.connectInstructions(h, { slot: "onboarding-connect", framework: state.selectedFramework ?? "nodejs", - apiKey: connect.keys._tag === "Ready" ? connect.keys.privateKey : "", + keys: keysOf(connect), setupTabs: connect.setupTabs, packageTabs: connect.packageTabs, snippetCopies: connect.snippetCopies, diff --git a/apps/web/src/page/overview/setup-checklist-view.ts b/apps/web/src/page/overview/setup-checklist-view.ts index 783005d626..3b9a5d86c8 100644 --- a/apps/web/src/page/overview/setup-checklist-view.ts +++ b/apps/web/src/page/overview/setup-checklist-view.ts @@ -99,7 +99,7 @@ const guidedSetup = (h: H, model: Model, framework: string): Html => GuidedSetup.connectInstructions(h, { slot: "overview-setup", framework, - apiKey: model.keys._tag === "Ready" ? model.keys.privateKey : "", + keys: model.keys._tag === "Ready" ? model.keys : GuidedSetup.NO_KEYS, setupTabs: model.setupTabs, packageTabs: model.packageTabs, snippetCopies: model.snippetCopies, diff --git a/apps/web/src/page/settings/ingestion/view-setup.ts b/apps/web/src/page/settings/ingestion/view-setup.ts index c21daad67f..0b5a2c2ba7 100644 --- a/apps/web/src/page/settings/ingestion/view-setup.ts +++ b/apps/web/src/page/settings/ingestion/view-setup.ts @@ -16,11 +16,11 @@ export const frameworkPicker = (h: H, selected: string): Html => onSelect: (framework) => Message.ClickedFramework({ framework }), }) -export const connectInstructions = (h: H, model: Model, apiKey: string): Html => +export const connectInstructions = (h: H, model: Model, keys: GuidedSetup.SetupKeys): Html => GuidedSetup.connectInstructions(h, { slot: "ingestion", framework: model.framework, - apiKey, + keys, setupTabs: model.setupTabs, packageTabs: model.packageTabs, snippetCopies: model.snippetCopies, diff --git a/apps/web/src/page/settings/ingestion/view.ts b/apps/web/src/page/settings/ingestion/view.ts index 7acaecc8c0..8582f2b84d 100644 --- a/apps/web/src/page/settings/ingestion/view.ts +++ b/apps/web/src/page/settings/ingestion/view.ts @@ -16,6 +16,7 @@ import { Message } from "./message" import { canManageRules, canRollKeys, canViewKeys, type Model } from "./model" import { credentialRow, ingestKeyRows } from "./view-keys" import { connectInstructions, frameworkIconStrip, frameworkPicker } from "./view-setup" +import { NO_KEYS, type SetupKeys } from "../../kit/guided-setup" import { deleteMappingDialog, mappingsSection, recommendationsSection } from "./view-rules" import { deleteRuleDialog, ingestRulesSection } from "./view-ingest-rules" import { typography } from "@maple/views/ui/typography" @@ -34,9 +35,12 @@ const connectionOf = (model: Model) => { isConnected: real.length > 0, serviceCount: real.length, spansPerMinute: Arr.reduce(real, 0, (sum, row) => sum + row.spanCount) / 60, - privateKey: AsyncData.getOrElse( - AsyncData.map(model.ingest, (keys) => keys.private_key), - () => "", + keys: AsyncData.getOrElse( + AsyncData.map(model.ingest, (keys): SetupKeys => ({ + privateKey: keys.private_key, + publicKey: keys.public_key, + })), + () => NO_KEYS, ), } } @@ -197,14 +201,14 @@ const addServiceSection = (h: H, model: Model): Html => { [ // Its own wrapping row, so the chips never crowd the section heading. h.div([h.Class("border-b px-4 py-3")], [frameworkPicker(h, model.framework)]), - connectInstructions(h, model, connection.privateKey), + connectInstructions(h, model, connection.keys), ], ) : hstack(h, { gap: "3", className: "px-4 py-3" }, [ frameworkIconStrip(h), h.span( [h.Class("text-muted-foreground min-w-0 grow truncate text-xs")], - ["Next.js, Node.js, Python, Go, Effect and more"], + ["Node.js, Python, Go, Java, Ruby, the browser and more"], ), button( h, diff --git a/apps/web/src/page/sources/kit/snippets.ts b/apps/web/src/page/sources/kit/snippets.ts index d2838617a7..758f612419 100644 --- a/apps/web/src/page/sources/kit/snippets.ts +++ b/apps/web/src/page/sources/kit/snippets.ts @@ -1,5 +1,5 @@ import type { SourcePlatform } from "@maple/domain/primitives" -import { type FrameworkId, sdkSnippets } from "../../../components/quick-start/sdk-snippets" +import { type FrameworkId, fillSnippet, sdkSnippets } from "../../../components/quick-start/sdk-snippets" import { ingestUrl } from "../../../lib/services/common/ingest-url" import { dockerCommand, HOST_CONFIG, helmCommand, hostCommand } from "../../infra/kit/install-snippets" import { Array as Arr, String as Str, Option } from "effect" @@ -34,7 +34,7 @@ const collectorSnippet = (token: string): SourceSnippet => ({ code: `exporters:\n otlphttp/maple:\n endpoint: ${ingestUrl}\n headers:\n Authorization: "Bearer ${token}"`, }) -// Reuses the quick-start SDK snippets for the languages that have one. +// Reuses the quick-start SDK snippets for the languages that have one. They read the env snippet above. const sdkFramework = (platform: SourcePlatform): FrameworkId | null => platform === "nodejs" || platform === "python" || platform === "go" ? platform : null @@ -46,11 +46,8 @@ const sdkSnippet = (platform: SourcePlatform, token: string): SourceSnippet | nu return { id: "sdk", label: `${snippet.label} SDK`, - language: framework === "python" ? "python" : framework === "go" ? "go" : "typescript", - code: Str.replace( - /\{\{API_KEY\}\}/g, - token, - )(Str.replace(/\{\{INGEST_URL\}\}/g, ingestUrl)(snippet.instrument)), + language: snippet.code.lang, + code: fillSnippet(snippet.code.code, ingestUrl, token), } } From 22d63b3c3a92ce889b4ae868da032d994a983dcf Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 15:48:34 +0100 Subject: [PATCH 3/9] fix(onboarding): members set up with the public ingest key instead of hitting a dead end An invited teammate joins as a member and could not read either key, so the connect step showed placeholders and "An admin can copy it". GET /v2/ingest_keys/public returns the public key to any member (new ingest_keys:view_public permission). It is made to ship in browser bundles and only sends telemetry, so it adds no exposure. The onboarding connect step and the overview setup card read both keys when they can, and fall back to the public key, with a line saying who can hand out the private one. --- .../routes/v2/config-resources.http.test.ts | 19 ++++++++++ apps/api/src/routes/v2/ingest-keys.http.ts | 17 ++++++++- .../MapleAPI/Sources/MapleAPI/openapi.json | 2 +- .../quick-start-agent-prompt.scene.test.ts | 30 ++++++++++++--- .../page/onboarding/quick-start-connect.ts | 27 ++++++++++---- .../src/page/onboarding/quick-start-view.ts | 10 ++++- .../page/overview/setup-checklist-command.ts | 19 +++++++--- .../src/page/overview/setup-checklist-view.ts | 9 +++-- apps/web/src/page/overview/setup-checklist.ts | 5 ++- docs/api-v2.md | 2 +- packages/domain/src/http/v2/ingest-keys.ts | 37 ++++++++++++++++++- packages/domain/src/http/v2/openapi.test.ts | 1 + packages/domain/src/policy/permissions.ts | 3 ++ 13 files changed, 149 insertions(+), 32 deletions(-) diff --git a/apps/api/src/routes/v2/config-resources.http.test.ts b/apps/api/src/routes/v2/config-resources.http.test.ts index 2d61759465..e0ca47ecec 100644 --- a/apps/api/src/routes/v2/config-resources.http.test.ts +++ b/apps/api/src/routes/v2/config-resources.http.test.ts @@ -512,6 +512,25 @@ describe("v2 ingest_keys over HTTP", () => { await harness.dispose() }) + it("lets a member read the public key alone, and only engineers and admins both", async () => { + const harness = makeHarness() + const admin = await harness.bootstrapKey() + const member = await harness.bootstrapMemberKey() + + const both = await harness.request("GET", "/v2/ingest_keys", { token: admin.secret }) + const publicOnly = await harness.request("GET", "/v2/ingest_keys/public", { token: member.secret }) + expect(publicOnly.status).toBe(200) + expect(publicOnly.body).toEqual({ + object: "public_ingest_key", + public_key: both.body.public_key, + public_rotated_at: both.body.public_rotated_at, + }) + + const denied = await harness.request("GET", "/v2/ingest_keys", { token: member.secret }) + expect(denied.status).toBe(403) + await harness.dispose() + }) + it("enforces the ingest_keys scope family", async () => { const harness = makeHarness() const scoped = await harness.bootstrapKey(["dashboards:read"]) diff --git a/apps/api/src/routes/v2/ingest-keys.http.ts b/apps/api/src/routes/v2/ingest-keys.http.ts index 19722ee698..25260926f4 100644 --- a/apps/api/src/routes/v2/ingest-keys.http.ts +++ b/apps/api/src/routes/v2/ingest-keys.http.ts @@ -2,7 +2,7 @@ import { HttpApiBuilder } from "effect/http-api" import type { IngestKeysResponse } from "@maple/domain/http" import { CurrentTenant } from "@maple/domain/http" import { MapleApiV2, V2InsufficientPermissions } from "@maple/domain/http/v2" -import type { V2IngestKeys } from "@maple/domain/http/v2" +import type { V2IngestKeys, V2PublicIngestKey } from "@maple/domain/http/v2" import { Effect } from "effect" import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService" import { OrgIngestKeysService } from "@maple/backend/services/org/OrgIngestKeysService" @@ -32,6 +32,21 @@ export const HttpV2IngestKeysLive = HttpApiBuilder.group(MapleApiV2, "ingestKeys return toV2IngestKeys(keys) }), ) + .handle("retrievePublic", () => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* Policy.check(tenant.roles, "ingest_keys:view_public", (denial) => + V2InsufficientPermissions.make(denial.message), + ) + const keys = yield* ingestKeys.getOrCreate(tenant.orgId, tenant.userId) + + return { + object: "public_ingest_key", + public_key: keys.publicKey, + public_rotated_at: keys.publicRotatedAt, + } satisfies V2PublicIngestKey + }), + ) .handle("rollPublic", () => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index 256e4c53e5..4980e62033 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -14273,7 +14273,7 @@ "name": "Alert Maintenance Windows" }, { - "description": "Telemetry ingest credentials for your organization: a public key for client-side senders and a private key for server-side senders. Retrieve them or roll either key. All operations are admin-only.", + "description": "Telemetry ingest credentials for your organization: a public key for client-side senders and a private key for server-side senders. Retrieve them or roll either key. Any member may read the public key; reading both keys takes an engineer or admin, and rolling one takes an admin.", "name": "Ingest Keys" }, { diff --git a/apps/web/src/page/onboarding/quick-start-agent-prompt.scene.test.ts b/apps/web/src/page/onboarding/quick-start-agent-prompt.scene.test.ts index 2a305b7821..75bb907c2f 100644 --- a/apps/web/src/page/onboarding/quick-start-agent-prompt.scene.test.ts +++ b/apps/web/src/page/onboarding/quick-start-agent-prompt.scene.test.ts @@ -1,5 +1,5 @@ import type { Html, HtmlBuilder } from "foldkit/html" -import { click, Command, expect as expectView, given, Mount, role, scene } from "foldkit/scene" +import { click, Command, expect as expectView, given, Mount, role, scene, text } from "foldkit/scene" import { describe, test } from "vitest" import { agentSetupPrompt } from "../../components/ingest/onboard-skill" import { ingestUrl } from "../../lib/services/common/ingest-url" @@ -17,15 +17,12 @@ const state: QuickStartState = { stack: { languages: ["nodejs"], platforms: [] }, } -const atConnect = (): Model => { +const atConnect = (privateKey: string | null = "maple_sk_test"): Model => { const model = init("maple-onboarding-v6-org_1", false, false, "/", "org_1").model return { ...model, state, - connect: { - ...model.connect, - keys: { _tag: "Ready", privateKey: "maple_sk_test", publicKey: "maple_pk_test" }, - }, + connect: { ...model.connect, keys: { _tag: "Ready", privateKey, publicKey: "maple_pk_test" } }, } } @@ -65,4 +62,25 @@ describe("build with your agent", () => { ), ) }) + + test("a member, who reads only the public key, gets it in the prompt and is told whom to ask", () => { + scene( + { update, view }, + given(atConnect(null)), + indicatorMeasured(), + expectView(text(/^This is your org's public key\./)).toExist(), + click(role("button", { name: "Build with your agent" })), + Command.expectExact( + CopyButton.WriteClipboard({ + text: agentSetupPrompt(ingestUrl, "maple_pk_test"), + generation: 1, + }), + ), + Command.resolve(CopyButton.WriteClipboard, CopyButton.Message.SucceededCopy({ generation: 1 })), + Command.resolve( + CopyButton.WaitCopyStatus, + CopyButton.Message.ElapsedCopyStatus({ generation: 1 }), + ), + ) + }) }) diff --git a/apps/web/src/page/onboarding/quick-start-connect.ts b/apps/web/src/page/onboarding/quick-start-connect.ts index e9fba4f9a7..91a280b9cb 100644 --- a/apps/web/src/page/onboarding/quick-start-connect.ts +++ b/apps/web/src/page/onboarding/quick-start-connect.ts @@ -27,8 +27,9 @@ const isStatusProvider = (platform: StackPlatform): platform is StatusProvider = const Keys = Schema.Union([ Schema.TaggedStruct("Pending", {}), - Schema.TaggedStruct("Ready", { privateKey: Schema.String, publicKey: Schema.String }), - /** The read failed (not an admin, or the key store is down): the prompt shows a placeholder. */ + /** `privateKey` is null for a member: they read the public key only, which sends from servers too. */ + Schema.TaggedStruct("Ready", { privateKey: Schema.NullOr(Schema.String), publicKey: Schema.String }), + /** Neither key could be read (the key store is down): the snippets show a placeholder. */ Schema.TaggedStruct("Failed", {}), ]) @@ -45,7 +46,7 @@ export const Connect = Schema.Struct({ export type Connect = typeof Connect.Type export const ConnectMessage = defineMessageUnion({ - SucceededLoadKeys: { privateKey: Schema.String, publicKey: Schema.String }, + SucceededLoadKeys: { privateKey: Schema.NullOr(Schema.String), publicKey: Schema.String }, FailedLoadKeys: {}, LoadedPresence: { result: Schema.Result(SignalPresences, LoadFailure) }, LoadedConnected: { connected: Schema.Record(Schema.String, Schema.Boolean) }, @@ -72,12 +73,22 @@ export const initConnect = (): Connect => ({ const FetchConnectKeys = Command.define("FetchOnboardingConnectKeys", { messages: [ConnectMessage.SucceededLoadKeys, ConnectMessage.FailedLoadKeys], - execute: Effect.flatMap(MapleApiV2Client, (client) => client.ingestKeys.retrieve()).pipe( - Effect.map((keys) => - ConnectMessage.SucceededLoadKeys({ privateKey: keys.private_key, publicKey: keys.public_key }), + execute: Effect.flatMap(MapleApiV2Client, (client) => + client.ingestKeys.retrieve().pipe( + Effect.map((keys) => + ConnectMessage.SucceededLoadKeys({ + privateKey: keys.private_key, + publicKey: keys.public_key, + }), + ), + // A member may not read the private key; the public one is theirs to use. + Effect.catch(() => + Effect.map(client.ingestKeys.retrievePublic(), (key) => + ConnectMessage.SucceededLoadKeys({ privateKey: null, publicKey: key.public_key }), + ), + ), ), - Effect.orElseSucceed(() => ConnectMessage.FailedLoadKeys()), - ), + ).pipe(Effect.orElseSucceed(() => ConnectMessage.FailedLoadKeys())), }) const LoadConnectPresence = Command.define("LoadOnboardingConnectPresence", { diff --git a/apps/web/src/page/onboarding/quick-start-view.ts b/apps/web/src/page/onboarding/quick-start-view.ts index 2604421680..b0c4184f8b 100644 --- a/apps/web/src/page/onboarding/quick-start-view.ts +++ b/apps/web/src/page/onboarding/quick-start-view.ts @@ -676,10 +676,16 @@ export const connectStep = (h: H, model: Model, state: QuickStartState): Html => ...(connect.keys._tag === "Failed" ? [ typography(h, { size: "xs", tone: "muted", as: "p" }, [ - "Couldn't read your ingest key. An admin can copy it from Settings, Ingestion.", + "Couldn't read your ingest key. Try again in a minute, or copy it from Settings, Ingestion.", ]), ] - : []), + : connect.keys._tag === "Ready" && connect.keys.privateKey === null + ? [ + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + "This is your org's public key. It works from servers too. For production servers, an engineer or admin can give you the private key from Settings, Ingestion.", + ]), + ] + : []), ]), stepFooter( h, diff --git a/apps/web/src/page/overview/setup-checklist-command.ts b/apps/web/src/page/overview/setup-checklist-command.ts index 96ec8ddeae..0c3e4867e1 100644 --- a/apps/web/src/page/overview/setup-checklist-command.ts +++ b/apps/web/src/page/overview/setup-checklist-command.ts @@ -22,15 +22,22 @@ export const SaveSetupState = Command.define("SaveSetupChecklistState", { Effect.as(writeQuickStartState(storageKey, state), Message.CompletedSaveState()), }) -/** `retainedQueryV2("ingestKeys", "retrieve", {})`. */ +/** Both ingest keys, or the public one alone for a member. */ export const FetchSetupKeys = Command.define("FetchSetupChecklistKeys", { messages: [Message.SucceededLoadKeys, Message.FailedLoadKeys], - execute: Effect.flatMap(MapleApiV2Client, (client) => client.ingestKeys.retrieve()).pipe( - Effect.map((keys) => - Message.SucceededLoadKeys({ publicKey: keys.public_key, privateKey: keys.private_key }), + execute: Effect.flatMap(MapleApiV2Client, (client) => + client.ingestKeys.retrieve().pipe( + Effect.map((keys) => + Message.SucceededLoadKeys({ publicKey: keys.public_key, privateKey: keys.private_key }), + ), + // A member may not read the private key; the public one is theirs to use. + Effect.catch(() => + Effect.map(client.ingestKeys.retrievePublic(), (key) => + Message.SucceededLoadKeys({ publicKey: key.public_key, privateKey: null }), + ), + ), ), - Effect.orElseSucceed(() => Message.FailedLoadKeys()), - ), + ).pipe(Effect.orElseSucceed(() => Message.FailedLoadKeys())), }) /** `getServiceOverviewResultAtom({ data: { startTime, endTime } })`, the pin applied ("top"). */ diff --git a/apps/web/src/page/overview/setup-checklist-view.ts b/apps/web/src/page/overview/setup-checklist-view.ts index 3b9a5d86c8..a437daffaa 100644 --- a/apps/web/src/page/overview/setup-checklist-view.ts +++ b/apps/web/src/page/overview/setup-checklist-view.ts @@ -21,7 +21,7 @@ import { hstack } from "@maple/views/ui/stack" type H = HtmlBuilder -/** `IngestKeysUnavailableNote`: members cannot read the org's ingest keys. */ +/** `IngestKeysUnavailableNote`: neither key could be read. */ const keysUnavailable = (h: H): Html => h.p( [ @@ -30,7 +30,7 @@ const keysUnavailable = (h: H): Html => ), ], [ - "Ask an org admin for your ingest keys, or open ", + "Couldn't read your ingest keys. Try again in a minute, or open ", h.a( [ h.Href(hrefOf(AppRoute.Settings({ search: { tab: "ingestion" } }))), @@ -69,8 +69,9 @@ const credentials = (h: H, model: Model): Html => ) : copyableField(h, { id: "setup-private-key", - label: "Private ingest key", - value: model.keys.privateKey, + label: + model.keys.privateKey === null ? "Public ingest key" : "Private ingest key", + value: model.keys.privateKey ?? model.keys.publicKey, copy: model.copies.privateKey, toCopyMessage: (message) => Message.GotCopyMessage({ field: "privateKey", message }), diff --git a/apps/web/src/page/overview/setup-checklist.ts b/apps/web/src/page/overview/setup-checklist.ts index 279f283514..43b5509512 100644 --- a/apps/web/src/page/overview/setup-checklist.ts +++ b/apps/web/src/page/overview/setup-checklist.ts @@ -14,7 +14,8 @@ import { QuickStartState } from "../onboarding/quick-start-state" export const Keys = Schema.Union([ // Not "Loading": the keys are read only while the card shows, so this can hold for the whole visit. Schema.TaggedStruct("Pending", {}), - Schema.TaggedStruct("Ready", { publicKey: Schema.String, privateKey: Schema.String }), + /** `privateKey` is null for a member, who reads the public key only. */ + Schema.TaggedStruct("Ready", { publicKey: Schema.String, privateKey: Schema.NullOr(Schema.String) }), Schema.TaggedStruct("Failed", {}), ]) export type Keys = typeof Keys.Type @@ -55,7 +56,7 @@ export type Model = typeof Model.Type export const Message = defineMessageUnion({ ReadState: { state: QuickStartState }, - SucceededLoadKeys: { publicKey: Schema.String, privateKey: Schema.String }, + SucceededLoadKeys: { publicKey: Schema.String, privateKey: Schema.NullOr(Schema.String) }, FailedLoadKeys: {}, SucceededLoadOverview: { window: TimeWindow, services: Schema.Array(ServiceRow) }, FailedLoadOverview: { window: TimeWindow, failure: LoadFailure }, diff --git a/docs/api-v2.md b/docs/api-v2.md index d590b27fd1..672cceb8a8 100644 --- a/docs/api-v2.md +++ b/docs/api-v2.md @@ -161,7 +161,7 @@ Implemented in phases; the pilot (`api_keys`) ships first and proves every conve | Resource | Endpoints | Backing service | | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | `api_keys` ✅ pilot | list/create/retrieve/roll/revoke, `scopes` param | `ApiKeysService` | -| `ingest_keys` ✅ | retrieve, `POST …/public/roll`, `POST …/private/roll` | `OrgIngestKeysService` | +| `ingest_keys` ✅ | retrieve, `GET …/public`, `POST …/public/roll`, `POST …/private/roll` | `OrgIngestKeysService` | | `dashboards` ✅ | CRUD + `versions` (list/retrieve/restore) + `templates` (list/preview/instantiate) + Perses import | `dashboards` | | `alerts/rules` ✅ | CRUD + `test` + `preview` + `checks` | `AlertsService` | | `alerts/destinations` ✅ | CRUD + `test` | `AlertsService` | diff --git a/packages/domain/src/http/v2/ingest-keys.ts b/packages/domain/src/http/v2/ingest-keys.ts index 1b5a975364..1a8cd65292 100644 --- a/packages/domain/src/http/v2/ingest-keys.ts +++ b/packages/domain/src/http/v2/ingest-keys.ts @@ -47,6 +47,28 @@ export const V2IngestKeys = Schema.Struct({ }) export type V2IngestKeys = Schema.Schema.Type +export const V2PublicIngestKey = Schema.Struct({ + object: Schema.Literal("public_ingest_key").annotate({ + description: 'The object type, always `"public_ingest_key"`.', + examples: ["public_ingest_key"], + }), + public_key: V2IngestKeys.fields.public_key, + public_rotated_at: V2IngestKeys.fields.public_rotated_at, +}).annotate({ + identifier: "PublicIngestKey", + title: "Public Ingest Key", + description: + "Your organization's public ingest key alone. Every member may read it: it is made to ship in browser and mobile code, and it can only send telemetry.", + examples: [ + wireExample({ + object: "public_ingest_key", + public_key: ingestKeysExample.public_key, + public_rotated_at: ingestKeysExample.public_rotated_at, + }), + ], +}) +export type V2PublicIngestKey = Schema.Schema.Type + const ingestKeyErrors = publicErrors(IngestKeyPersistenceError, IngestKeyEncryptionError) export class V2IngestKeysApiGroup extends HttpApiGroup.make("ingestKeys") @@ -63,6 +85,19 @@ export class V2IngestKeysApiGroup extends HttpApiGroup.make("ingestKeys") }), ), ) + .add( + HttpApiEndpoint.get("retrievePublic", "/public", { + success: V2PublicIngestKey, + error: [V2InsufficientPermissions.schema, ...ingestKeyErrors], + }).annotateMerge( + OpenApi.annotations({ + identifier: "getPublicIngestKey", + summary: "Retrieve the public ingest key", + description: + "Returns your organization's public ingest key, creating the keys on first access. Any member may call it. Requires the `ingest_keys:read` scope.", + }), + ), + ) .add( HttpApiEndpoint.post("rollPublic", "/public/roll", { success: V2IngestKeys, @@ -95,6 +130,6 @@ export class V2IngestKeysApiGroup extends HttpApiGroup.make("ingestKeys") OpenApi.annotations({ title: "Ingest Keys", description: - "Telemetry ingest credentials for your organization: a public key for client-side senders and a private key for server-side senders. Retrieve them or roll either key. All operations are admin-only.", + "Telemetry ingest credentials for your organization: a public key for client-side senders and a private key for server-side senders. Retrieve them or roll either key. Any member may read the public key; reading both keys takes an engineer or admin, and rolling one takes an admin.", }), ) {} diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index 9978313693..7e4f78df98 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -156,6 +156,7 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/error_issues/service_counts", "GET /v2/error_issues/{id}", "GET /v2/ingest_keys", + "GET /v2/ingest_keys/public", "GET /v2/ingest_rules", "GET /v2/ingest_rules/stats", "GET /v2/ingest_rules/{id}", diff --git a/packages/domain/src/policy/permissions.ts b/packages/domain/src/policy/permissions.ts index 89be267629..8fc76219ab 100644 --- a/packages/domain/src/policy/permissions.ts +++ b/packages/domain/src/policy/permissions.ts @@ -8,6 +8,9 @@ import type { Role } from "./roles" * Not to be confused with `@maple/domain/permission`, which decides what an agent may run. */ export const permissions = { + // The public ingest key ships in browser bundles, so every member may read it to set up an app. + "ingest_keys:view_public": { min: "member", action: "view the public ingest key" }, + // Technical setup: engineers and above. "integrations:manage": { min: "engineer", action: "manage integrations" }, "code_review:configure": { min: "engineer", action: "change code review settings" }, From 05e4b720d41fd8f9fb2f7d99de2532b230fa7802 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 15:56:02 +0100 Subject: [PATCH 4/9] fix(onboarding): the Ruby card links the new Ruby and Rails guide --- apps/web/src/lib/docs.ts | 1 + apps/web/src/lib/onboarding-stack.ts | 2 +- apps/web/src/page/kit/guided-setup.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/docs.ts b/apps/web/src/lib/docs.ts index 3c23ec2db4..279e61f939 100644 --- a/apps/web/src/lib/docs.ts +++ b/apps/web/src/lib/docs.ts @@ -14,6 +14,7 @@ export const DOCS = { kotlinGuide: "/docs/guides/instrumentation-kotlin", dotnetGuide: "/docs/guides/instrumentation-csharp", laravelGuide: "/docs/guides/instrumentation-laravel", + rubyGuide: "/docs/guides/instrumentation-ruby", effectSdk: "/docs/sdks/effect", quickstart: "/docs/getting-started/quickstart", otelConventions: "/docs/concepts/otel-conventions", diff --git a/apps/web/src/lib/onboarding-stack.ts b/apps/web/src/lib/onboarding-stack.ts index b959c344a7..115d506b09 100644 --- a/apps/web/src/lib/onboarding-stack.ts +++ b/apps/web/src/lib/onboarding-stack.ts @@ -131,7 +131,7 @@ export const STACK_LANGUAGE_DOCS = { nextjs: { label: "Next.js", page: "nextjsGuide" }, dotnet: { label: ".NET", page: "dotnetGuide" }, php: { label: "Laravel", page: "laravelGuide" }, - ruby: { label: "OpenTelemetry", page: "instrumentation" }, + ruby: { label: "Ruby", page: "rubyGuide" }, kotlin: { label: "Kotlin", page: "kotlinGuide" }, bun: { label: "OpenTelemetry", page: "instrumentation" }, deno: { label: "OpenTelemetry", page: "instrumentation" }, diff --git a/apps/web/src/page/kit/guided-setup.ts b/apps/web/src/page/kit/guided-setup.ts index d988311189..96791bae62 100644 --- a/apps/web/src/page/kit/guided-setup.ts +++ b/apps/web/src/page/kit/guided-setup.ts @@ -55,7 +55,7 @@ const FRAMEWORK_DOCS = { java: { label: "Java", page: "javaGuide" }, dotnet: { label: ".NET", page: "dotnetGuide" }, laravel: { label: "Laravel", page: "laravelGuide" }, - ruby: { label: "OpenTelemetry", page: "instrumentation" }, + ruby: { label: "Ruby", page: "rubyGuide" }, rust: { label: "Rust", page: "rustGuide" }, deno: { label: "OpenTelemetry", page: "instrumentation" }, effect: { label: "Effect", page: "effectSdk" }, From 4b946dbd0e6b63a40160b62e0f7bd25863348b1b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 16:02:01 +0100 Subject: [PATCH 5/9] fix(onboarding): .NET and Python send over HTTP, Rust crates pinned, Sources shows the install step The agent prompt carries the key the picked stack uses, so a browser-only stack never hands the private key to an agent writing frontend code. --- .../components/quick-start/sdk-snippets.ts | 24 +++++---- apps/web/src/page/kit/guided-setup.test.ts | 28 +++++++++-- apps/web/src/page/kit/guided-setup.ts | 11 +++- .../src/page/onboarding/quick-start-view.ts | 2 +- .../web/src/page/sources/kit/snippets.test.ts | 11 ++++ apps/web/src/page/sources/kit/snippets.ts | 50 +++++++++++++------ 6 files changed, 94 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/quick-start/sdk-snippets.ts b/apps/web/src/components/quick-start/sdk-snippets.ts index 8a8d6647ca..b9321bd1e7 100644 --- a/apps/web/src/components/quick-start/sdk-snippets.ts +++ b/apps/web/src/components/quick-start/sdk-snippets.ts @@ -36,12 +36,18 @@ export interface SdkSnippet { // The same setups as the docs quickstart: the key lives in the environment, never in a source file, // and runtimes with a zero-code agent use it, so every request is traced, not one test span. -const otlpEnv = (serviceName: string): SnippetBlock => ({ +/** + * Set where the SDK's default exporter is gRPC but the setup installs or expects the HTTP one + * (Python's distro, .NET's `UseOtlpExporter`). OTLP/HTTP is the path every Maple endpoint serves. + */ +export const HTTP_PROTOCOL_LINE = `export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"` + +const otlpEnv = (serviceName: string, protocol: "default" | "http" = "default"): SnippetBlock => ({ label: "Environment", lang: "bash", code: `export OTEL_EXPORTER_OTLP_ENDPOINT="{{INGEST_URL}}" export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer {{API_KEY}}" -export OTEL_SERVICE_NAME="${serviceName}"`, +export OTEL_SERVICE_NAME="${serviceName}"${protocol === "http" ? `\n${HTTP_PROTOCOL_LINE}` : ""}`, }) export const sdkSnippets: ReadonlyArray = [ @@ -56,6 +62,7 @@ export const sdkSnippets: ReadonlyArray = [ label: "Run", lang: "bash", code: `# Start your app through the register hook. No code changes. +# ES modules: see the Node.js guide below. node --require @opentelemetry/auto-instrumentations-node/register app.js`, }, }, @@ -105,11 +112,7 @@ MapleBrowser.init({ key: "private", install: `pip install opentelemetry-distro opentelemetry-exporter-otlp-proto-http opentelemetry-bootstrap -a install`, - env: { - ...otlpEnv("my-service"), - code: `${otlpEnv("my-service").code} -export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"`, - }, + env: otlpEnv("my-service", "http"), code: { label: "Run", lang: "bash", @@ -188,7 +191,7 @@ java -javaagent:opentelemetry-javaagent.jar -jar app.jar`, install: `dotnet add package OpenTelemetry.Extensions.Hosting dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol dotnet add package OpenTelemetry.Instrumentation.AspNetCore`, - env: otlpEnv("my-service"), + env: otlpEnv("my-service", "http"), code: { label: "Program.cs", lang: "csharp", @@ -252,8 +255,9 @@ OpenTelemetry::SDK.configure(&:use_all)`, label: "Rust", description: "Axum, Tokio", key: "private", - install: `cargo add opentelemetry opentelemetry_sdk opentelemetry-otlp tracing tracing-subscriber -cargo add tracing-opentelemetry@0.32`, + // The opentelemetry crates share one minor version, and tracing-opentelemetry 0.32 is built for 0.31. + install: `cargo add opentelemetry@0.31 opentelemetry_sdk@0.31 opentelemetry-otlp@0.31 tracing-opentelemetry@0.32 +cargo add tracing tracing-subscriber`, env: otlpEnv("my-service"), code: { label: "src/main.rs", diff --git a/apps/web/src/page/kit/guided-setup.test.ts b/apps/web/src/page/kit/guided-setup.test.ts index 32235b3a01..085ddd615a 100644 --- a/apps/web/src/page/kit/guided-setup.test.ts +++ b/apps/web/src/page/kit/guided-setup.test.ts @@ -1,7 +1,7 @@ import { Array as Arr, Option } from "effect" import { describe, expect, test } from "vitest" import { type FrameworkId, sdkSnippets } from "../../components/quick-start/sdk-snippets" -import { NO_KEYS, serverKey, type SetupKeys, setupBlocks } from "./guided-setup" +import { NO_KEYS, promptKey, serverKey, type SetupKeys, setupBlocks } from "./guided-setup" const KEYS = { privateKey: "maple_sk_secret", publicKey: "maple_pk_public" } @@ -58,9 +58,24 @@ describe("setup snippets", () => { expect(codeOf("python")).toContain('OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"') }) - test("the Node install has every package the setup needs", () => { - expect(installOf("nodejs")).toBe("@opentelemetry/api @opentelemetry/auto-instrumentations-node") - expect(codeOf("nodejs")).not.toContain("@opentelemetry/sdk-logs") + test("the Node install has the package the run command loads", () => { + expect(codeOf("nodejs")).toContain("--require @opentelemetry/auto-instrumentations-node/register") + expect(installOf("nodejs")).toContain("@opentelemetry/auto-instrumentations-node") + }) + + test("SDKs whose default exporter is gRPC are told to send over HTTP", () => { + for (const framework of ["python", "dotnet"] as const) + expect(codeOf(framework)).toContain('OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"') + }) + + test("the Rust crates are pinned to versions built for each other", () => { + expect(installOf("rust")).toContain("opentelemetry@0.31") + expect(installOf("rust")).toContain("tracing-opentelemetry@0.32") + }) + + test("the coding-agent prompt carries the key the picked stack's setup uses", () => { + expect(promptKey("browser", KEYS)).toBe("maple_pk_public") + expect(promptKey("python", KEYS)).toBe("maple_sk_secret") }) test("Ruby gets a Rails initializer, not a pointer to opentelemetry.io", () => { @@ -75,7 +90,10 @@ describe("setup snippets", () => { expect(serverKey(KEYS)).toBe("maple_sk_secret") }) - test("without a key the setup shows a placeholder", () => { + test("without a key the setup shows a placeholder, never another key", () => { expect(codeOf("nodejs", NO_KEYS)).toContain("Authorization=Bearer ") + expect(codeOf("browser", { privateKey: "maple_sk_secret", publicKey: null })).toContain( + 'ingestKey: ""', + ) }) }) diff --git a/apps/web/src/page/kit/guided-setup.ts b/apps/web/src/page/kit/guided-setup.ts index 96791bae62..7f97b9ce94 100644 --- a/apps/web/src/page/kit/guided-setup.ts +++ b/apps/web/src/page/kit/guided-setup.ts @@ -99,9 +99,16 @@ export const setupBlocks = (framework: string, keys: SetupKeys): ReadonlyArray keyFor({ key: "private" }, keys) +/** The key the coding-agent prompts carry: the one the picked stack's setup uses (public for the browser). */ +export const promptKey = (framework: string, keys: SetupKeys): string | null => + Option.match( + Arr.findFirst(sdkSnippets, (entry) => entry.language === framework), + { onNone: () => serverKey(keys), onSome: (snippet) => keyFor(snippet, keys) }, + ) + const SETUP_TAB_ICONS = { install: "DownloadIcon", instrument: "CodeIcon", @@ -446,7 +453,7 @@ export const connectInstructions = (h: HtmlBuilder, parts: InstructionsPar h, parts, "skill-prompt", - onboardSkillPrompt(ingestUrl, serverKey(parts.keys) ?? ""), + onboardSkillPrompt(ingestUrl, promptKey(parts.framework, parts.keys) ?? ""), "prompt", REPLAY_BLOCK_CLASS, true, diff --git a/apps/web/src/page/onboarding/quick-start-view.ts b/apps/web/src/page/onboarding/quick-start-view.ts index 2604421680..57fad107b3 100644 --- a/apps/web/src/page/onboarding/quick-start-view.ts +++ b/apps/web/src/page/onboarding/quick-start-view.ts @@ -652,7 +652,7 @@ export const connectStep = (h: H, model: Model, state: QuickStartState): Html => ), ]), GuidedSetup.agentPromptButton(h, { - apiKey: GuidedSetup.serverKey(keysOf(connect)), + apiKey: GuidedSetup.promptKey(state.selectedFramework ?? "nodejs", keysOf(connect)), copy: connect.snippetCopies[AGENT_PROMPT_COPY] ?? CopyButton.init(), toCopyMessage: (message) => toConnect( diff --git a/apps/web/src/page/sources/kit/snippets.test.ts b/apps/web/src/page/sources/kit/snippets.test.ts index 9cd5f74244..8583fd20f1 100644 --- a/apps/web/src/page/sources/kit/snippets.test.ts +++ b/apps/web/src/page/sources/kit/snippets.test.ts @@ -1,3 +1,4 @@ +import { Array as Arr } from "effect" import { describe, expect, it } from "vitest" import { sourceSnippets } from "./snippets" @@ -7,4 +8,14 @@ describe("sourceSnippets", () => { expect(browser?.code).toContain('serviceName: "marketing-site"') expect(browser?.code).toContain('ingestKey: "maple_pk_test"') }) + + it("gives a Python source everything to start sending: env over HTTP, the install, then the run", () => { + const snippets = sourceSnippets("python", "maple_src_test", "Billing API") + expect(Arr.map(snippets, (snippet) => snippet.id)).toEqual(["env", "install", "sdk"]) + const [env, install, run] = snippets + expect(env?.code).toContain('OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"') + expect(env?.code).toContain('OTEL_SERVICE_NAME="billing-api"') + expect(install?.code).toContain("opentelemetry-distro") + expect(run?.code).toContain("opentelemetry-instrument") + }) }) diff --git a/apps/web/src/page/sources/kit/snippets.ts b/apps/web/src/page/sources/kit/snippets.ts index 758f612419..49acf6b1dc 100644 --- a/apps/web/src/page/sources/kit/snippets.ts +++ b/apps/web/src/page/sources/kit/snippets.ts @@ -1,5 +1,11 @@ import type { SourcePlatform } from "@maple/domain/primitives" -import { type FrameworkId, fillSnippet, sdkSnippets } from "../../../components/quick-start/sdk-snippets" +import { + type FrameworkId, + fillSnippet, + HTTP_PROTOCOL_LINE, + type SdkSnippet, + sdkSnippets, +} from "../../../components/quick-start/sdk-snippets" import { ingestUrl } from "../../../lib/services/common/ingest-url" import { dockerCommand, HOST_CONFIG, helmCommand, hostCommand } from "../../infra/kit/install-snippets" import { Array as Arr, String as Str, Option } from "effect" @@ -13,15 +19,16 @@ export interface SourceSnippet { readonly code: string } -const envSnippet = (token: string, serviceName: string): SourceSnippet => ({ +const envSnippet = (token: string, serviceName: string, needsHttp = false): SourceSnippet => ({ id: "env", label: "Environment", language: "shell", code: Arr.join( [ `export OTEL_EXPORTER_OTLP_ENDPOINT="${ingestUrl}"`, - `export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer%20${token}"`, + `export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer ${token}"`, `export OTEL_SERVICE_NAME="${serviceName}"`, + ...(needsHttp ? [HTTP_PROTOCOL_LINE] : []), ], "\n", ), @@ -34,22 +41,35 @@ const collectorSnippet = (token: string): SourceSnippet => ({ code: `exporters:\n otlphttp/maple:\n endpoint: ${ingestUrl}\n headers:\n Authorization: "Bearer ${token}"`, }) -// Reuses the quick-start SDK snippets for the languages that have one. They read the env snippet above. +// Reuses the quick-start SDK setups for the languages that have one: install, then run. They read +// the env snippet above. const sdkFramework = (platform: SourcePlatform): FrameworkId | null => platform === "nodejs" || platform === "python" || platform === "go" ? platform : null -const sdkSnippet = (platform: SourcePlatform, token: string): SourceSnippet | null => { +const sdkOf = (platform: SourcePlatform): SdkSnippet | null => { const framework = sdkFramework(platform) - const found = Arr.findFirst(sdkSnippets, (entry) => entry.language === framework) - if (framework === null || Option.isNone(found)) return null - const snippet = found.value - return { + return framework === null + ? null + : Option.getOrNull(Arr.findFirst(sdkSnippets, (entry) => entry.language === framework)) +} + +const setupSnippets = (snippet: SdkSnippet, token: string): ReadonlyArray => [ + { + id: "install", + label: "Install", + language: "shell", + code: + typeof snippet.install === "string" + ? snippet.install + : `npm install ${Arr.join(snippet.install.packages, " ")}`, + }, + { id: "sdk", - label: `${snippet.label} SDK`, + label: snippet.code.label === "Run" ? `Run with the ${snippet.label} SDK` : snippet.code.label, language: snippet.code.lang, code: fillSnippet(snippet.code.code, ingestUrl, token), - } -} + }, +] const browserSnippet = (token: string, serviceName: string): SourceSnippet => ({ id: "browser", @@ -108,8 +128,10 @@ export const sourceSnippets = ( case "cloudflare_ai_gateway": return [envSnippet(token, serviceName), collectorSnippet(token)] default: { - const sdk = sdkSnippet(platform, token) - return sdk === null ? [envSnippet(token, serviceName)] : [envSnippet(token, serviceName), sdk] + const sdk = sdkOf(platform) + if (sdk === null) return [envSnippet(token, serviceName)] + const needsHttp = sdk.env !== null && Str.includes(HTTP_PROTOCOL_LINE)(sdk.env.code) + return [envSnippet(token, serviceName, needsHttp), ...setupSnippets(sdk, token)] } } } From 63bffd1c2dbdc0ccf4a1c1b419d8e4bed40a5733 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 16:03:41 +0100 Subject: [PATCH 6/9] fix(onboarding): only a permission refusal falls back to the public key A key store outage stays a failure, so an admin is never handed the public key and told to ask an engineer for the private one. One shared read serves the connect step and the overview card. --- apps/web/src/data/ingest-keys.test.ts | 48 ++++++++++++++++--- apps/web/src/data/ingest-keys.ts | 15 ++++-- apps/web/src/page/overview/setup-checklist.ts | 2 +- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/apps/web/src/data/ingest-keys.test.ts b/apps/web/src/data/ingest-keys.test.ts index 1f71777566..1590904f6b 100644 --- a/apps/web/src/data/ingest-keys.test.ts +++ b/apps/web/src/data/ingest-keys.test.ts @@ -1,7 +1,7 @@ import { CurrentTenant } from "@maple/domain/http" import { MapleApiV2 } from "@maple/domain/http/v2" import { Effect, Layer, Predicate, Ref, Schema, String as Str } from "effect" -import { HttpClient, HttpClientResponse } from "effect/http" +import { HttpClient, type HttpClientRequest, HttpClientResponse } from "effect/http" import { HttpApiClient } from "effect/http-api" import { describe, expect, test } from "vitest" import { MapleApiV2Client } from "../platform/api-clients" @@ -22,20 +22,40 @@ const publicKey = { public_rotated_at: "2026-11-01T00:00:00Z", } +const denied = { + error: { + _tag: "@maple/http/v2/InsufficientPermissionsError", + type: "permission_error", + code: "insufficient_permissions", + title: "Permission required", + message: "Only org admins and engineers can view ingest keys.", + retryable: false, + recovery: "request_access", + }, +} + +/** What `GET /v2/ingest_keys` answers; the public read always succeeds. */ +type BothKeysAnswer = "keys" | "denied" | "outage" + +const answer = (request: HttpClientRequest.HttpClientRequest, both: BothKeysAnswer): Response => + Str.endsWith("/public")(request.url) + ? Response.json(publicKey) + : both === "keys" + ? Response.json(keys) + : both === "denied" + ? Response.json(denied, { status: 403 }) + : new Response("upstream down", { status: 503 }) + const run = ( roles: ReadonlyArray, read: Effect.Effect, + both: BothKeysAnswer = "keys", ) => Effect.gen(function* () { const sent = yield* Ref.make(0) const http = HttpClient.make((request) => Ref.update(sent, (count) => count + 1).pipe( - Effect.as( - HttpClientResponse.fromWeb( - request, - Response.json(Str.endsWith("/public")(request.url) ? publicKey : keys), - ), - ), + Effect.as(HttpClientResponse.fromWeb(request, answer(request, both))), ), ) const tenant = Layer.succeed(SessionTenant, { @@ -84,4 +104,18 @@ describe("reading the ingest keys", () => { expect.objectContaining({ success: { privateKey: "maple_sk_test", publicKey: "maple_pk_test" } }), ) }) + + test("a refusal from the API itself also falls back to the public key", async () => { + const { result, requests } = await run(["org:engineer"], retrieveSetupKeys, "denied") + expect(requests).toBe(2) + expect(result).toEqual( + expect.objectContaining({ success: { privateKey: null, publicKey: "maple_pk_test" } }), + ) + }) + + test("a key store outage stays a failure, so an admin is never handed the public key instead", async () => { + const { result, requests } = await run(["org:admin"], retrieveSetupKeys, "outage") + expect(requests).toBe(1) + expect(result._tag).toBe("Failure") + }) }) diff --git a/apps/web/src/data/ingest-keys.ts b/apps/web/src/data/ingest-keys.ts index 653ec3fd04..dd533425b3 100644 --- a/apps/web/src/data/ingest-keys.ts +++ b/apps/web/src/data/ingest-keys.ts @@ -1,6 +1,6 @@ import * as Policy from "@maple/domain/policy" import { V2InsufficientPermissions } from "@maple/domain/http/v2" -import { Effect, Option } from "effect" +import { Effect, Option, Predicate, Schema } from "effect" import { MapleApiV2Client } from "../platform/api-clients" import { SessionTenant } from "./session-tenant" @@ -16,6 +16,14 @@ export const retrieveIngestKeys = Effect.gen(function* () { return yield* (yield* MapleApiV2Client).ingestKeys.retrieve() }) +const DeniedEnvelope = Schema.Struct({ + error: Schema.Struct({ code: Schema.Literal(V2InsufficientPermissions.code) }), +}) + +/** The refusal as the API answers it (the decoded envelope) or as the session check makes it (the class). */ +const isPermissionDenied = (error: unknown): boolean => + Predicate.isTagged(error, V2InsufficientPermissions.tag) || Schema.is(DeniedEnvelope)(error) + /** The keys a setup screen shows: `privateKey` is null for a member, who reads the public one only. */ export type SetupKeyPair = Readonly<{ privateKey: string | null; publicKey: string }> @@ -24,9 +32,8 @@ export const retrieveSetupKeys = Effect.gen(function* () { const client = yield* MapleApiV2Client return yield* retrieveIngestKeys.pipe( Effect.map((keys): SetupKeyPair => ({ privateKey: keys.private_key, publicKey: keys.public_key })), - // The 403 arrives as the decoded public envelope, not the tagged class; any failure here - // falls back to the read every member may make, and that one's failure is the answer. - Effect.catch(() => + // Only a refusal falls back: a key store outage must not hand an admin the public key. + Effect.catchIf(isPermissionDenied, () => Effect.map(client.ingestKeys.retrievePublic(), (key): SetupKeyPair => ({ privateKey: null, publicKey: key.public_key, diff --git a/apps/web/src/page/overview/setup-checklist.ts b/apps/web/src/page/overview/setup-checklist.ts index 43b5509512..ed55a87f3f 100644 --- a/apps/web/src/page/overview/setup-checklist.ts +++ b/apps/web/src/page/overview/setup-checklist.ts @@ -10,7 +10,7 @@ import { QuickStartState } from "../onboarding/quick-start-state" // The setup checklist: Clerk mode's // "Connect your app" card over `useQuickStart` and `useIngestConnection`. -/** `readIngestKeys`: both keys from one admin-only read. */ +/** Both keys for an engineer and up, the public one alone for a member (`retrieveSetupKeys`). */ export const Keys = Schema.Union([ // Not "Loading": the keys are read only while the card shows, so this can hold for the whole visit. Schema.TaggedStruct("Pending", {}), From 201ec7e53a42fbf416330dc3d1a627d880a0c07a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 16:16:33 +0100 Subject: [PATCH 7/9] fix(onboarding): the Node note does not point at a guide below the code --- apps/web/src/components/quick-start/sdk-snippets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/quick-start/sdk-snippets.ts b/apps/web/src/components/quick-start/sdk-snippets.ts index b9321bd1e7..33ba97c8f7 100644 --- a/apps/web/src/components/quick-start/sdk-snippets.ts +++ b/apps/web/src/components/quick-start/sdk-snippets.ts @@ -62,7 +62,7 @@ export const sdkSnippets: ReadonlyArray = [ label: "Run", lang: "bash", code: `# Start your app through the register hook. No code changes. -# ES modules: see the Node.js guide below. +# ES modules: see the Node.js guide. node --require @opentelemetry/auto-instrumentations-node/register app.js`, }, }, From b41be8e9b074ef5887a5ff9bcf85faa9e3dbd0c6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 16:18:15 +0100 Subject: [PATCH 8/9] chore(web): regenerate the kit index for the setup key helpers --- packages/views/src/ui/INDEX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index fbb81c5399..26f53bd799 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -189,7 +189,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/detail-header.ts` `detailEyebrow`, `detailTitle`, `detailTitleSkeleton`, `detailActions`: `DetailHeader`'s title side and `DetailHeaderSkeleton`. - `apps/web/src/page/kit/form-section.ts` `formSection`, `formScrollArea`, `formCard`, `formActionBar`, `managersOnlyPage`: `FormSection`: one row of a form card, what it sets on the left (a title and an optional hint) and its controls on the right; the two stack on phones. - `apps/web/src/page/kit/grid-list-row.ts` `gridListRow`: `GridListRow`: one `
  • ` of a bordered grid list (the alerts inbox, rules and resolved lists), padded, tinted on hover and press, positioned so a stretched row link covers it. -- `apps/web/src/page/kit/guided-setup.ts` `frameworkIconStrip`, `frameworkPicker`, `agentPromptButton`, `connectInstructions`: `FrameworkIconStrip`, `FrameworkPicker` and `ConnectInstructions`, with `PackageManagerCodeBlock`. +- `apps/web/src/page/kit/guided-setup.ts` `keyFor`, `setupBlocks`, `serverKey`, `promptKey`, `frameworkIconStrip`, `frameworkPicker`, `agentPromptButton`, `connectInstructions`: `FrameworkIconStrip`, `FrameworkPicker` and `ConnectInstructions`, with `PackageManagerCodeBlock`. - `apps/web/src/page/kit/infra-correlation/command.ts` `operationOf`: The four `*InfraTimeseries` reads the panel's charts make, through the read cache under the atom key (`requestKey(org, "", { data })`), so a reopened panel reuses them. - `apps/web/src/page/kit/infra-correlation/update.ts` `chartIdOf`, `requestsOf` (stateful: Model, init, update): A chart's slot and chart id, `-` (the linked-cursor id). - `apps/web/src/page/kit/infra-correlation/view-charts.ts` `correlationCharts`: `renderCharts`: each group's charts, drawn as the detail charts. From 1eea0df57528f3065df829d0a11c9c662b144d9c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 16:24:34 +0100 Subject: [PATCH 9/9] fix(onboarding): a failed key read sends members to someone who can see the keys Settings, Ingestion shows no key to a member, so the note asks an engineer or admin instead of pointing there. The overview card's member label is covered by a scene test. --- apps/web/src/page/onboarding/quick-start-view.ts | 2 +- .../src/page/overview/setup-checklist-view.ts | 2 +- .../page/overview/setup-checklist.scene.test.ts | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/web/src/page/onboarding/quick-start-view.ts b/apps/web/src/page/onboarding/quick-start-view.ts index 4ca1e2f615..7faea9bc72 100644 --- a/apps/web/src/page/onboarding/quick-start-view.ts +++ b/apps/web/src/page/onboarding/quick-start-view.ts @@ -676,7 +676,7 @@ export const connectStep = (h: H, model: Model, state: QuickStartState): Html => ...(connect.keys._tag === "Failed" ? [ typography(h, { size: "xs", tone: "muted", as: "p" }, [ - "Couldn't read your ingest key. Try again in a minute, or copy it from Settings, Ingestion.", + "Couldn't read your ingest key. Try again in a minute, or ask an engineer or admin to copy it from Settings, Ingestion.", ]), ] : connect.keys._tag === "Ready" && connect.keys.privateKey === null diff --git a/apps/web/src/page/overview/setup-checklist-view.ts b/apps/web/src/page/overview/setup-checklist-view.ts index a437daffaa..b470306c60 100644 --- a/apps/web/src/page/overview/setup-checklist-view.ts +++ b/apps/web/src/page/overview/setup-checklist-view.ts @@ -30,7 +30,7 @@ const keysUnavailable = (h: H): Html => ), ], [ - "Couldn't read your ingest keys. Try again in a minute, or open ", + "Couldn't read your ingest keys. Try again in a minute, or ask an engineer or admin to copy one from ", h.a( [ h.Href(hrefOf(AppRoute.Settings({ search: { tab: "ingestion" } }))), diff --git a/apps/web/src/page/overview/setup-checklist.scene.test.ts b/apps/web/src/page/overview/setup-checklist.scene.test.ts index c21cbd1361..60bb68f605 100644 --- a/apps/web/src/page/overview/setup-checklist.scene.test.ts +++ b/apps/web/src/page/overview/setup-checklist.scene.test.ts @@ -118,6 +118,22 @@ describe("overview setup checklist", () => { ) }) + test("a member's card labels the key it shows as the public one", () => { + const waiting = withTracePresence(withServices(entered(true).read.model, []), "absent") + const memberKeys = pageUpdate( + waiting, + setup(SetupMessage.SucceededLoadKeys({ publicKey: "maple_pk_member", privateKey: null })), + ).model + scene( + { update: pageUpdate, view }, + given(memberKeys), + ...pageMounts, + ...tabMounts, + expectView(text("Public ingest key")).toExist(), + expectView(text("Private ingest key")).not.toExist(), + ) + }) + test("an org whose traces are older than the last hour is not asked to connect, nor polled", () => { const quiet = withServices(entered(true).read.model, []) const seenBefore = withTracePresence(quiet, "present")