Skip to content

feat(appkit): relay App Analytics records to the OTel Collector - #612

Draft
ditadi wants to merge 1 commit into
stack/app-analytics/01-sdkfrom
stack/app-analytics/02-relay
Draft

ditadi wants to merge 1 commit into
stack/app-analytics/01-sdkfrom
stack/app-analytics/02-relay

Conversation

@ditadi

@ditadi ditadi commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Why the change

Browsers can't reach the Databricks Apps OTel Collector, which listens only on localhost, so the server plugin now relays App Analytics records from POST /_analytics/v1/logs to the Collector, and AppKit apps need no server code to collect them.

Special things to note

  • The route is on by default in every AppKit app. server({ appAnalytics: false }) removes it. It adds no auth of its own and relies on the Databricks Apps proxy in front of the app, like the app's other routes.
  • The relay parses its own body with a 64 KiB limit and skips the server's global JSON parser, so bodyLimit doesn't apply to it. It checks only the outer shape (resourceLogs is an array). The Collector decides what to accept, and its status is passed back.
  • Requests under /_analytics/ are kept out of AppKit's own spans, wide-event logs, and request metrics, so browser traffic doesn't flood the app's server telemetry.

Change outline

The server plugin mounts one guarded route and marks its prefix as excluded:

 packages/appkit/src/
 ├── plugins/server/
+│   ├── app-analytics-relay.ts   # endpoint resolution, guard, relay handler
 │   ├── index.ts                 # mounts the route unless appAnalytics === false
 │   ├── manifest.json            # appAnalytics: boolean, default true
 │   └── types.ts                 # ServerConfig.appAnalytics
 └── utils/
     └── path-exclusions.ts       # + "/_analytics/"

The request path, including every answer the route can give:

POST /_analytics/v1/logs
  appAnalyticsGuard
    not application/json               → 415
    express.json({ limit: "64kb" })
      over 64 KiB                      → 413
      invalid JSON                     → 400
    resourceLogs is not an array       → 400
  appAnalyticsRelay
    resolveOtlpLogsEndpoint()
      OTEL_EXPORTER_OTLP_LOGS_ENDPOINT          as-is
      else OTEL_EXPORTER_OTLP_ENDPOINT + "/v1/logs"
      neither set (App telemetry off)  → 204, warn once, records discarded
    forwardOtlpLogs(body)              # no incoming headers, no redirects, 5 s timeout
      Collector answers                → Collector's status
      unreachable or timed out         → 502, warn once

Every answer has an empty body. The 204 when telemetry is off keeps local development quiet, because the browser SDK treats it as delivered and neither retries nor reports a failure.

Wiring in ServerPlugin:

 this.registerEndpoint("health", "/health");
+if (this.config.appAnalytics !== false) {
+  this.rawBodyPaths.add(APP_ANALYTICS_PATH);   // global JSON parser skips it
+  this.serverApplication.post(APP_ANALYTICS_PATH, appAnalyticsGuard(), appAnalyticsRelay());
+}

 requestMetricsMiddleware(req, res, next)
+  if (req.path.startsWith("/_analytics/")) return next();

@databricks/app-analytics becomes a devDependency of @databricks/appkit, used only by the integration test. That test drives the real SDK (#611) through the relay into a fake Collector.

This pull request and its description were written by Isaac.

Browsers can't reach the Databricks Apps OTel Collector, which listens
only on localhost. The server plugin now answers POST /_analytics/v1/logs,
the default endpoint of @databricks/app-analytics, and forwards the
OTLP/HTTP JSON body unchanged to the Collector, resolved from
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT or OTEL_EXPORTER_OTLP_ENDPOINT.

The route has its own 64 KiB JSON parser and answers bad input itself:
415 for a non-JSON content type, 413 for oversized bodies, and 400 for
malformed JSON or a body without a resourceLogs array. Otherwise it
returns the Collector's status. An unreachable or slow Collector yields
502 and logs one warning. When App telemetry is off the relay answers
204 and warns once.

server({ appAnalytics: false }) removes the route. Paths under
/_analytics/ are excluded from spans, wide-event logs, and request
metrics.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: ditadi <victordperd@gmail.com>
@ditadi
ditadi added this pull request to stack #616 September 26, 2026 21:42
@github-actions

Copy link
Copy Markdown
Contributor

📦 Bundle size report

Compared against bundle-size-baseline.json (main).

@databricks/appkit

npm tarball (packed): 1.2 MB (+5.5 KB) — gzipped download (dist + bin; excludes release-only docs/NOTICE).

dist raw gzip
JS (runtime) 1.2 MB (+6.1 KB) 427 KB (+2.4 KB)
Type declarations 444 KB (+513 B) 161 KB (+241 B)
Source maps 2.4 MB (+9.4 KB) 801 KB (+3.6 KB)
Other 11 KB 3.7 KB
Total 4.0 MB (+16 KB) 1.4 MB (+6.3 KB)
Per-entry composition (own code — deps external (as shipped))
Entry Initial (gz) Lazy (gz) Total (gz) node_modules (min) Own code (min)
. 97 KB (+743 B) 2.5 KB (-1 B) 99 KB (+742 B) external 316 KB (+1.9 KB)
./beta 93 KB (+5 B) 456 B (-1 B) 93 KB (+4 B) external 281 KB (+15 B)
./testing 38 KB (+8 B) 31 KB (+738 B) 69 KB (+746 B) external 203 KB (+1.9 KB)
./tsdown 520 B 0 B 520 B external 813 B
./type-generator 23 KB (+4 B) 0 B 23 KB (+4 B) external 65 KB (+15 B)

Chunks:

Entry Chunk Load Size (gz)
. index.js initial 93 KB
. utils.js initial 4.0 KB
. remote-tunnel-manager.js lazy 2.5 KB
./beta beta.js initial 77 KB
./beta stream-manager.js initial 5.8 KB
./beta wide-event-emitter.js initial 3.2 KB
./beta databricks.js initial 3.2 KB
./beta configuration.js initial 2.3 KB
./beta service-context.js initial 1.3 KB
./beta client.js initial 434 B
./beta client-options.js initial 219 B
./beta supervisor-api.js lazy 192 B
./beta databricks.js lazy 141 B
./beta index.js lazy 123 B
./testing manifest.js initial 26 KB
./testing index.js initial 10.0 KB
./testing wide-event-emitter.js initial 2.9 KB
./testing index.js lazy 27 KB
./testing remote-tunnel-manager.js lazy 2.5 KB
./testing utils.js lazy 1.2 KB
./tsdown index.js initial 520 B
./type-generator index.js initial 23 KB

@databricks/appkit-ui

npm tarball (packed): 350 KB (-4 B) — gzipped download (dist + bin; excludes release-only docs/NOTICE).

dist raw gzip
JS (runtime) 395 KB 132 KB
Type declarations 229 KB 84 KB (-1 B)
Source maps 766 KB 253 KB
CSS 16 KB 3.2 KB
Total 1.4 MB 472 KB (-1 B)
Per-entry composition (consumer bundle — deps bundled, peerDeps external)
Entry Initial (gz) Lazy (gz) Total (gz) node_modules (min) Own code (min)
./js 5.3 KB 49 KB 55 KB 208 KB 14 KB
./js/beta 20 B 0 B 20 B 0 B 0 B
./react 432 KB 49 KB 481 KB 1.3 MB 177 KB
./react/beta 1.0 KB 0 B 1.0 KB 0 B 1.9 KB

Chunks:

Entry Chunk Load Size (gz)
./js index.js initial 5.2 KB
./js chunk initial 120 B
./js apache-arrow lazy 49 KB
./js/beta beta.js initial 20 B
./react index.js initial 430 KB
./react tslib initial 2.1 KB
./react apache-arrow lazy 49 KB
./react/beta beta.js initial 1.0 KB

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AppKit PR bot

🔬 Run evals

Start an eval for this PR from the evals-monitor app: Go to Evals Monitor →

📦 Try this PR's app template

Scaffolds a new app from this PR's SDK build. Run it in any folder (requires the GitHub CLI — gh auth login — and the Databricks CLI):

gh run download 36273883393 -R databricks/appkit -n appkit-template-0.78.0-pr.07c9567-stack-app-analytics-02-relay-612 -D appkit-pr-612 \
  && unzip -o "appkit-pr-612/appkit-template-0.78.0-pr.07c9567-stack-app-analytics-02-relay-612.zip" -d "appkit-pr-612" \
  && databricks apps init --template "appkit-pr-612"

The template pins @databricks/appkit and @databricks/appkit-ui to tarballs built from this branch, so the scaffolded app runs against this PR's code.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant