Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
59458ca
feat(appkit): send internal telemetry via AppkitLog schema
calvarjorge Apr 29, 2026
4e720ed
fix(playground): bind DATABRICKS_JOB_ID in app.yaml
calvarjorge Apr 29, 2026
af6f6f3
fix(appkit): use /telemetry-ext and resolve redirect locations
calvarjorge Apr 29, 2026
e45773b
refactor(appkit): drop legacy observability_log startup telemetry
calvarjorge Apr 30, 2026
b207f28
fix(appkit): read app_id from DATABRICKS_CLIENT_ID
calvarjorge Apr 30, 2026
986c42f
refactor(appkit): rename internal telemetry kill-switch env var
calvarjorge Apr 30, 2026
f7ac315
refactor(appkit): inline sender.ts into reporter
calvarjorge Apr 30, 2026
651377d
docs: add public internal-telemetry page
calvarjorge Apr 30, 2026
688059e
fix(appkit): harden telemetry dispatch + revert knip cdxgen change
calvarjorge Apr 30, 2026
eab7c4b
refactor(appkit): hoist redirect body.cancel() out of the branch
calvarjorge Apr 30, 2026
08378b9
chore(appkit): drop redirect-follow logic to test endpoint behavior
calvarjorge Apr 30, 2026
f955015
chore(appkit): drop redirect handling and dev-playground doc section
calvarjorge Apr 30, 2026
6e80df3
chore(appkit): drop public TelemetryReporter exports + dev-playground…
calvarjorge May 6, 2026
fb4e05a
docs(appkit): rename internal-telemetry page to Privacy + DO_NOT_TRACK
calvarjorge May 6, 2026
db00fcd
chore(appkit): drop .js suffixes from internal-telemetry imports
calvarjorge May 6, 2026
9547764
docs(appkit): trim Privacy page
calvarjorge May 6, 2026
ce2e36e
chore(playground): drop appkit.plugins.json
calvarjorge May 6, 2026
a9f6a18
chore(appkit): send empty bodies for APP_STARTUP and HEARTBEAT
calvarjorge May 6, 2026
be24186
refactor(appkit): send telemetry via client.apiClient.request
calvarjorge May 6, 2026
6894085
fix(appkit): stop telemetry reporter on graceful shutdown + cover mid…
calvarjorge May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ coverage
.turbo

.databricks

.superset/config.json
Comment thread
calvarjorge marked this conversation as resolved.
2 changes: 2 additions & 0 deletions apps/dev-playground/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ env:
valueFrom: genie-space
- name: DATABRICKS_SERVING_ENDPOINT_NAME
valueFrom: serving-endpoint
- name: DATABRICKS_JOB_ID
valueFrom: job
Comment on lines +8 to +9
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As long as we don't have databricks.yml it won't help as we need to set the resource manually 🤔 Maybe it's better to skip it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My deployed dev-playground did not start, as it said it was missing this env var. Do you have a different set up that works?

# Files plugin manifest declares a static DATABRICKS_VOLUME_FILES
# requirement; keep it bound so appkit's runtime validation passes
# even though the policy harness below uses its own keys.
Expand Down
4 changes: 3 additions & 1 deletion docs/docs/api/appkit/Function.createApp.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
function createApp<T>(config: {
cache?: CacheConfig;
client?: WorkspaceClient;
disableInternalTelemetry?: boolean;
onPluginsReady?: (appkit: PluginMap<T>) => void | Promise<void>;
plugins?: T;
telemetry?: TelemetryConfig;
Expand All @@ -30,9 +31,10 @@ with an `asUser(req)` method for user-scoped execution.

| Parameter | Type |
| ------ | ------ |
| `config` | \{ `cache?`: [`CacheConfig`](Interface.CacheConfig.md); `client?`: `WorkspaceClient`; `onPluginsReady?`: (`appkit`: `PluginMap`\<`T`\>) => `void` \| `Promise`\<`void`\>; `plugins?`: `T`; `telemetry?`: [`TelemetryConfig`](Interface.TelemetryConfig.md); \} |
| `config` | \{ `cache?`: [`CacheConfig`](Interface.CacheConfig.md); `client?`: `WorkspaceClient`; `disableInternalTelemetry?`: `boolean`; `onPluginsReady?`: (`appkit`: `PluginMap`\<`T`\>) => `void` \| `Promise`\<`void`\>; `plugins?`: `T`; `telemetry?`: [`TelemetryConfig`](Interface.TelemetryConfig.md); \} |
| `config.cache?` | [`CacheConfig`](Interface.CacheConfig.md) |
| `config.client?` | `WorkspaceClient` |
| `config.disableInternalTelemetry?` | `boolean` |
| `config.onPluginsReady?` | (`appkit`: `PluginMap`\<`T`\>) => `void` \| `Promise`\<`void`\> |
| `config.plugins?` | `T` |
| `config.telemetry?` | [`TelemetryConfig`](Interface.TelemetryConfig.md) |
Expand Down
50 changes: 50 additions & 0 deletions docs/docs/privacy.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
sidebar_position: 99
---

# Privacy

AppKit sends a small amount of anonymized usage telemetry to Databricks
so the team can understand how the SDK is used and prioritize
improvements. This page documents exactly what is sent, when, and how
to turn it off.

## What we collect

Every event is a single record with three top-level fields:

| Field | Type | Source |
| ---------------- | ------ | ----------------------------------- |
| `event_name` | enum | One of `APP_STARTUP`, `HEARTBEAT`, `REQUEST_METRICS` |
| `app_id` | string | The app's OAuth client UUID (`DATABRICKS_CLIENT_ID`) |
| `appkit_version` | string | The AppKit SDK version |

Each event also carries one of three event-specific bodies:

- **`APP_STARTUP`** — emitted once when `createApp` finishes booting.
Empty body.
- **`HEARTBEAT`** — emitted every five minutes from a running app.
Empty body.
- **`REQUEST_METRICS`** — emitted once per minute, one record per HTTP
endpoint that received traffic in the window. Each record contains:
- `endpoint` — the route template (e.g. `GET /api/genie/:space_id/messages`),
never the raw request URL or any user-provided values.
- `request_count`
- `request_latency_ms_avg`
- `response_count_http4xx`
- `response_count_http5xx`

## How to opt out

Set any one of the following:

```sh
# AppKit-specific kill switch
DISABLE_APPKIT_INTERNAL_TELEMETRY=true

# Cross-tool standard (https://consoledonottrack.com)
DO_NOT_TRACK=1
```

Either fully disables the reporter — no events are emitted and no
network calls are made.
23 changes: 23 additions & 0 deletions packages/appkit/src/core/appkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@ import type {
PluginData,
PluginMap,
} from "shared";
import { version as productVersion } from "../../package.json";
import { CacheManager } from "../cache";
import { ServiceContext } from "../context";
import {
isInternalTelemetryEnabled,
TelemetryReporter,
} from "../internal-telemetry";
import { createLogger } from "../logging/logger";
import { ResourceRegistry, ResourceType } from "../registry";
import type { TelemetryConfig } from "../telemetry";
Expand Down Expand Up @@ -171,6 +176,7 @@ export class AppKit<TPlugins extends InputPluginMap> {
cache?: CacheConfig;
client?: WorkspaceClient;
onPluginsReady?: (appkit: PluginMap<T>) => void | Promise<void>;
disableInternalTelemetry?: boolean;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd vote for removing that. IMO the environmental variable is enough 👍

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was the proposal in the design doc. Since we're obtaining some data from customer apps, we wanted to be as transparent as possible about it.

} = {},
): Promise<PluginMap<T>> {
// Initialize core services
Expand Down Expand Up @@ -212,6 +218,10 @@ export class AppKit<TPlugins extends InputPluginMap> {
logger.debug("onPluginsReady hook completed");
}

if (isInternalTelemetryEnabled(config)) {
AppKit.bootstrapInternalTelemetry();
}

const serverPlugin = instance.#pluginInstances.server;
if (serverPlugin && typeof (serverPlugin as any).start === "function") {
await (serverPlugin as any).start();
Expand All @@ -220,6 +230,18 @@ export class AppKit<TPlugins extends InputPluginMap> {
return handle;
}

private static bootstrapInternalTelemetry(): void {
const serviceCtx = ServiceContext.get();
const reporter = TelemetryReporter.initialize({
workspaceId: serviceCtx.workspaceId,
client: serviceCtx.client,
appId: process.env.DATABRICKS_CLIENT_ID || "",
appkitVersion: productVersion,
});
reporter.start();
reporter.sendStartup().catch(() => {});
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we are calling this (which is asynchronous) and basically ignoring the promise, is this on purpose?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we don't want to cause delays or raise errors because of internal telemetry.

}

private static preparePlugins(
plugins: PluginData<PluginConstructor, unknown, string>[],
) {
Expand Down Expand Up @@ -279,6 +301,7 @@ export async function createApp<
cache?: CacheConfig;
client?: WorkspaceClient;
onPluginsReady?: (appkit: PluginMap<T>) => void | Promise<void>;
disableInternalTelemetry?: boolean;
} = {},
): Promise<PluginMap<T>> {
return AppKit._createApp(config);
Expand Down
72 changes: 72 additions & 0 deletions packages/appkit/src/core/tests/databricks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ import type { PluginManifest } from "../../registry/types";
import { ResourceType } from "../../registry/types";
import { AppKit, createApp } from "../appkit";

const mockReporter = {
start: vi.fn(),
stop: vi.fn(),
sendStartup: vi.fn().mockResolvedValue(undefined),
sendHeartbeat: vi.fn().mockResolvedValue(undefined),
flushRequestMetrics: vi.fn().mockResolvedValue(undefined),
recordRequest: vi.fn(),
};

vi.mock("../../internal-telemetry", () => ({
isInternalTelemetryEnabled: vi.fn().mockReturnValue(true),
TelemetryReporter: {
initialize: vi.fn(() => mockReporter),
getInstance: vi.fn(() => mockReporter),
_reset: vi.fn(),
},
}));

// Generic test manifest for test plugins
const createTestManifest = (name: string): PluginManifest => ({
name,
Expand Down Expand Up @@ -630,6 +648,60 @@ describe("AppKit", () => {
});
});

describe("internal telemetry", () => {
test("initializes the reporter and fires sendStartup after createApp", async () => {
const { TelemetryReporter } = await import("../../internal-telemetry");
mockReporter.sendStartup.mockClear();
mockReporter.start.mockClear();
vi.mocked(TelemetryReporter.initialize).mockClear();

await createApp({
plugins: [{ plugin: CoreTestPlugin, config: {}, name: "coreTest" }],
});

// Allow the fire-and-forget promise chain to resolve
await new Promise((r) => setTimeout(r, 10));

expect(TelemetryReporter.initialize).toHaveBeenCalledWith(
expect.objectContaining({
appkitVersion: expect.any(String),
client: expect.anything(),
}),
);
expect(mockReporter.start).toHaveBeenCalledOnce();
expect(mockReporter.sendStartup).toHaveBeenCalledOnce();
});

test("skips bootstrap when isInternalTelemetryEnabled returns false", async () => {
const { isInternalTelemetryEnabled, TelemetryReporter } = await import(
"../../internal-telemetry"
);
vi.mocked(TelemetryReporter.initialize).mockClear();
mockReporter.sendStartup.mockClear();
vi.mocked(isInternalTelemetryEnabled).mockReturnValue(false);

await createApp({ plugins: [] });

await new Promise((r) => setTimeout(r, 10));

expect(TelemetryReporter.initialize).not.toHaveBeenCalled();
expect(mockReporter.sendStartup).not.toHaveBeenCalled();
vi.mocked(isInternalTelemetryEnabled).mockReturnValue(true);
});

test("does not crash startup if sendStartup rejects", async () => {
mockReporter.sendStartup.mockRejectedValueOnce(
new Error("telemetry failure"),
);

const instance = await createApp({
plugins: [{ plugin: CoreTestPlugin, config: {}, name: "coreTest" }],
});

expect(instance).toBeDefined();
});
});

describe("SDK context binding", () => {
test("should bind SDK methods to plugin instance", async () => {
class ContextTestPlugin implements BasePlugin {
Expand Down
58 changes: 58 additions & 0 deletions packages/appkit/src/internal-telemetry/appkit-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// IMPORTANT: keep this file in sync with the AppkitLog proto schema served by
// the Databricks client telemetry endpoint. Field names use proto JSON
// conventions (snake_case) so the wire format matches the backend.

export type AppkitEventName =
| "APPKIT_EVENT_NAME_UNSPECIFIED"
| "APP_STARTUP"
| "HEARTBEAT"
| "REQUEST_METRICS";

export type AppStartupEvent = Record<string, never>;

export type HeartbeatEvent = Record<string, never>;

export interface RequestMetricsEvent {
endpoint?: string;
request_count?: number;
request_latency_ms_avg?: number;
response_count_http4xx?: number;
response_count_http5xx?: number;
}

export interface AppkitLog {
Comment thread
calvarjorge marked this conversation as resolved.
event_name: AppkitEventName;
app_id?: string;
appkit_version?: string;
app_startup_event?: AppStartupEvent;
heartbeat_event?: HeartbeatEvent;
request_metrics_event?: RequestMetricsEvent;
}

interface AppkitLogEnvelope {
frontend_log_event_id: string;
inferred_timestamp_millis: number;
entry: { appkit_log: AppkitLog };
}

interface TelemetryPayload {
uploadTime: number;
items: never[];
protoLogs: string[];
}

export function wrapAppkitLog(log: AppkitLog): AppkitLogEnvelope {
return {
frontend_log_event_id: `appkit-${log.event_name.toLowerCase()}-${crypto.randomUUID()}`,
inferred_timestamp_millis: Date.now(),
entry: { appkit_log: log },
};
}

export function buildAppkitPayload(logs: AppkitLog[]): TelemetryPayload {
return {
uploadTime: Date.now(),
items: [],
protoLogs: logs.map((log) => JSON.stringify(wrapAppkitLog(log))),
};
}
13 changes: 13 additions & 0 deletions packages/appkit/src/internal-telemetry/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Checks whether internal telemetry is enabled.
* Shared across all telemetry event types (startup, heartbeat, metrics, etc.).
*/
export function isInternalTelemetryEnabled(opts?: {
disableInternalTelemetry?: boolean;
}): boolean {
if (opts?.disableInternalTelemetry) return false;
if (process.env.DISABLE_APPKIT_INTERNAL_TELEMETRY === "true") return false;
// Honor the cross-tool DO_NOT_TRACK convention (https://consoledonottrack.com).
if (process.env.DO_NOT_TRACK === "1") return false;
return true;
}
8 changes: 8 additions & 0 deletions packages/appkit/src/internal-telemetry/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Internal telemetry: APP_STARTUP, HEARTBEAT, and REQUEST_METRICS events
// POSTed to /telemetry-ext so the Databricks team can prioritize SDK work.
// Disable with disableInternalTelemetry: true on createApp,
// DISABLE_APPKIT_INTERNAL_TELEMETRY=true, or DO_NOT_TRACK=1.
// Full data inventory: docs/docs/privacy.mdx.

export { isInternalTelemetryEnabled } from "./config";
export { TelemetryReporter } from "./reporter";
Loading