Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
run: |
bun install
bun run build
Expand Down
2 changes: 1 addition & 1 deletion @blaxel/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"build:fix-esm": "node fix-esm-imports.js",
"build:esm-package": "echo '{\"type\":\"module\"}' > dist/esm/package.json",
"build:browser": "node fix-browser-imports.js",
"build:replace-imports": "node -e \"const fs=require('fs'),{execSync}=require('child_process'),pkg=require('./package.json');let commit='unknown';try{commit=execSync('git rev-parse HEAD',{encoding:'utf8'}).trim()}catch(e){console.log('⚠️ Could not get commit:',e.message)}const sentryDsn=process.env.SENTRY_DSN||'';const paths=['dist/cjs/common/settings.js','dist/esm/common/settings.js','dist/cjs-browser/common/settings.js','dist/esm-browser/common/settings.js'];paths.forEach(p=>{if(fs.existsSync(p)){let c=fs.readFileSync(p,'utf8');c=c.replace(/__BUILD_VERSION__/g,pkg.version);c=c.replace(/__BUILD_COMMIT__/g,commit);c=c.replace(/__BUILD_SENTRY_DSN__/g,sentryDsn);fs.writeFileSync(p,c);console.log('✅ Injected build info in',p,'(v'+pkg.version+', commit:'+commit.substring(0,7)+')');}});\"",
"build:replace-imports": "node -e \"const fs=require('fs'),{execSync}=require('child_process'),pkg=require('./package.json');let commit='unknown';try{commit=execSync('git rev-parse HEAD',{encoding:'utf8'}).trim()}catch(e){console.log('⚠️ Could not get commit:',e.message)}const sentryDsn=process.env.SENTRY_DSN||'';const posthogKey=process.env.POSTHOG_KEY||'';const settingsPaths=['dist/cjs/common/settings.js','dist/esm/common/settings.js','dist/cjs-browser/common/settings.js','dist/esm-browser/common/settings.js'];settingsPaths.forEach(p=>{if(fs.existsSync(p)){let c=fs.readFileSync(p,'utf8');c=c.replace(/__BUILD_VERSION__/g,pkg.version);c=c.replace(/__BUILD_COMMIT__/g,commit);c=c.replace(/__BUILD_SENTRY_DSN__/g,sentryDsn);fs.writeFileSync(p,c);console.log('✅ Injected build info in',p,'(v'+pkg.version+', commit:'+commit.substring(0,7)+')');}});const posthogPaths=['dist/cjs/common/posthog.js','dist/esm/common/posthog.js','dist/cjs-browser/common/posthog.js','dist/esm-browser/common/posthog.js'];posthogPaths.forEach(p=>{if(fs.existsSync(p)){let c=fs.readFileSync(p,'utf8');c=c.replace(/__BUILD_POSTHOG_KEY__/g,posthogKey);fs.writeFileSync(p,c);console.log('✅ Injected PostHog key in',p);}});\"",
"test": "vitest --run",
"test:watch": "vitest --watch"
},
Expand Down
86 changes: 51 additions & 35 deletions @blaxel/core/src/common/lazyInit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { client } from "../client/client.gen.js";
import { client as clientSandbox } from "../sandbox/client/client.gen.js";
import { trackSDKInstalled } from "./posthog.js";
import { initSentry } from "./sentry.js";
import { settings } from "./settings.js";

Expand All @@ -24,44 +25,59 @@ let autoloaded = false;
* first HTTP request.
*/
export function ensureAutoloaded(): void {
if (autoloaded) return;
autoloaded = true;
if (autoloaded) return;
autoloaded = true;

// Trigger lazy credential resolution. This may read `~/.blaxel/config.yaml`
// and set `process.env.BL_ENV` if the user has a dev workspace configured,
// which in turn affects `settings.baseUrl`.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
settings.credentials;
// Trigger lazy credential resolution. This may read `~/.blaxel/config.yaml`
// and set `process.env.BL_ENV` if the user has a dev workspace configured,
// which in turn affects `settings.baseUrl`.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
settings.credentials;

// Keep the clients' baseUrl in sync with the now-resolved env. Without
// this, the module-load `client.setConfig({ baseUrl })` would be stuck on
// the prod default for users who rely on `config.yaml` (no env vars).
client.setConfig({ baseUrl: settings.baseUrl });
clientSandbox.setConfig({ baseUrl: settings.baseUrl });
// Keep the clients' baseUrl in sync with the now-resolved env. Without
// this, the module-load `client.setConfig({ baseUrl })` would be stuck on
// the prod default for users who rely on `config.yaml` (no env vars).
client.setConfig({ baseUrl: settings.baseUrl });
clientSandbox.setConfig({ baseUrl: settings.baseUrl });

// Initialize Sentry for SDK error tracking.
initSentry();
// Initialize Sentry for SDK error tracking.
initSentry();

// Background H2 connection warming (Node.js only)
const isNode = typeof process !== "undefined" && process.versions != null && process.versions.node != null;
/* eslint-disable */
const isBrowser = typeof globalThis !== "undefined" && (globalThis as any)?.window !== undefined;
// Track SDK installation (fires once per new version)
try {
trackSDKInstalled();
} catch {
// Silently ignore - telemetry should never break the SDK
}

if (isNode && !isBrowser) {
try {
// Pre-warm edge H2 for the configured region so the first
// SandboxInstance.create() gets an instant session via the pool.
// The control-plane client (api.blaxel.ai) stays on regular fetch
// which already benefits from undici's built-in connection pooling.
const region = settings.region;
if (region) {
import("./h2pool.js").then(({ h2Pool }) => {
const edgeSuffix = settings.env === "prod" ? "bl.run" : "runv2.blaxel.dev";
h2Pool.warm(`any.${region}.${edgeSuffix}`);
}).catch(() => {});
}
} catch {
// Silently ignore warming failures
}
}
// Background H2 connection warming (Node.js only)
const isNode =
typeof process !== "undefined" &&
process.versions != null &&
process.versions.node != null;
/* eslint-disable */
const isBrowser =
typeof globalThis !== "undefined" &&
(globalThis as any)?.window !== undefined;

if (isNode && !isBrowser) {
try {
// Pre-warm edge H2 for the configured region so the first
// SandboxInstance.create() gets an instant session via the pool.
// The control-plane client (api.blaxel.ai) stays on regular fetch
// which already benefits from undici's built-in connection pooling.
const region = settings.region;
if (region) {
import("./h2pool.js")
.then(({ h2Pool }) => {
const edgeSuffix =
settings.env === "prod" ? "bl.run" : "runv2.blaxel.dev";
h2Pool.warm(`any.${region}.${edgeSuffix}`);
})
.catch(() => {});
}
} catch {
// Silently ignore warming failures
}
}
}
214 changes: 214 additions & 0 deletions @blaxel/core/src/common/posthog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { fs, os, path } from "../common/node.js";
import { settings } from "./settings.js";

// PostHog API key injected at build time via build:replace-imports
const BUILD_POSTHOG_KEY = "__BUILD_POSTHOG_KEY__";

// PostHog API endpoint
const POSTHOG_HOST = "https://us.i.posthog.com";

// Telemetry state file path: ~/.blaxel/telemetry.json
type TelemetryState = {
distinct_id: string;
cli?: string;
sdks?: Record<string, string>;
};

let telemetryState: TelemetryState | null = null;

/**
* Get the PostHog API key (injected at build time).
*/
function getPosthogKey(): string {
const key = BUILD_POSTHOG_KEY;
// If the placeholder was not replaced, treat as empty
if (!key || key.startsWith("__BUILD_")) {
return "";
}
return key;
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

/**
* Get the telemetry file path.
*/
function getTelemetryPath(): string | null {
if (os === null || path === null) {
return null;
}
try {
return path.join(os.homedir(), ".blaxel", "telemetry.json");
} catch {
return null;
}
}

/**
* Load telemetry state from disk.
*/
function loadTelemetryState(): TelemetryState {
if (telemetryState !== null) {
return telemetryState;
}

telemetryState = { distinct_id: "", sdks: {} };

if (fs === null) {
return telemetryState;
}

const telemetryPath = getTelemetryPath();
if (!telemetryPath) {
return telemetryState;
}

try {
const data = fs.readFileSync(telemetryPath, "utf8");
const parsed = JSON.parse(data) as TelemetryState;
telemetryState = {
...parsed,
distinct_id: parsed.distinct_id || "",
sdks: parsed.sdks || {},
};
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
} catch {
// File doesn't exist or is invalid - use defaults
}

return telemetryState;
}

/**
* Save telemetry state to disk.
*/
function saveTelemetryState(state: TelemetryState): void {
if (fs === null || path === null || os === null) {
return;
}

const telemetryPath = getTelemetryPath();
if (!telemetryPath) {
return;
}

try {
const dir = path.dirname(telemetryPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(telemetryPath, JSON.stringify(state, null, 2), {
mode: 0o600,
});
} catch {
// Silently fail
}
}

/**
* Generate a UUID v4 without external dependencies.
*/
function generateUUID(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}

/**
* Get or create a persistent distinct ID for PostHog events.
*/
function getDistinctID(): string {
const state = loadTelemetryState();
if (state.distinct_id) {
return state.distinct_id;
}
state.distinct_id = generateUUID();
saveTelemetryState(state);
return state.distinct_id;
}

/**
* Send an event to PostHog via HTTP POST. Fire-and-forget.
*/
function capturePosthogEvent(
event: string,
properties: Record<string, string>,
): void {
const apiKey = getPosthogKey();
if (!apiKey) {
return;
}

const distinctId = getDistinctID();

const payload = {
api_key: apiKey,
event,
distinct_id: distinctId,
timestamp: new Date().toISOString(),
properties,
};

try {
fetch(`${POSTHOG_HOST}/capture/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(5000),
}).catch(() => {
// Silently fail - telemetry should never break the SDK
});
} catch {
// Silently fail
}
}

/**
* Track SDK installation. Fires "Installed SDK" once per new version.
* Called during SDK autoload.
*/
export function trackSDKInstalled(): void {
const apiKey = getPosthogKey();
if (!apiKey) {
return;
}

// Only track if tracking is enabled
if (!settings.tracking) {
return;
}
Comment thread
mendral-app[bot] marked this conversation as resolved.
Comment thread
mendral-app[bot] marked this conversation as resolved.
Comment thread
mendral-app[bot] marked this conversation as resolved.
Comment on lines +179 to +181
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

bug (P0): !settings.tracking is opt-in: settings.tracking defaults to false, so telemetry never fires for any user who hasn't explicitly set tracking: true in config.yaml. The PR description states opt-out semantics.

Suggested change
Suggested change
if (!settings.tracking) {
return;
}
// Only track if tracking is not explicitly disabled
if (settings.tracking === false) {
return;
}
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At @blaxel/core/src/common/posthog.ts, line 179:

<issue>
`!settings.tracking` is opt-in: `settings.tracking` defaults to `false`, so telemetry never fires for any user who hasn't explicitly set `tracking: true` in `config.yaml`. The PR description states opt-out semantics.
</issue>

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 is by design — settings.tracking mirrors the existing Sentry tracking behavior in this SDK (see sentry.ts which uses the same settings.tracking guard). Both Sentry and PostHog telemetry use the same opt-in mechanism via config.yaml. This is consistent across the codebase.


const sdkVersion = settings.version;
if (
!sdkVersion ||
sdkVersion === "unknown" ||
sdkVersion === "__BUILD_VERSION__"
) {
return;
}

// Only run in Node.js (not in browsers)
if (typeof process === "undefined" || !process.versions?.node) {
return;
}

const state = loadTelemetryState();
const sdkName = "typescript";

if (state.sdks && state.sdks[sdkName] === sdkVersion) {
return; // Already tracked this version
}

capturePosthogEvent("Installed SDK", {
version: sdkVersion,
language: sdkName,
});

if (!state.sdks) {
state.sdks = {};
}
state.sdks[sdkName] = sdkVersion;
saveTelemetryState(state);
Comment thread
mendral-app[bot] marked this conversation as resolved.
Comment thread
mendral-app[bot] marked this conversation as resolved.
Comment thread
mendral-app[bot] marked this conversation as resolved.
Comment on lines +204 to +213
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

bug (P2): The version is saved to disk before the fire-and-forget fetch resolves. A silent network failure permanently suppresses retries for that version.

Suggested change
Suggested change
capturePosthogEvent("Installed SDK", {
version: sdkVersion,
language: sdkName,
});
if (!state.sdks) {
state.sdks = {};
}
state.sdks[sdkName] = sdkVersion;
saveTelemetryState(state);
// Optimistic dedup: mark as tracked before firing the request.
// Worst case on network failure is one missed event per version.
if (!state.sdks) {
state.sdks = {};
}
state.sdks[sdkName] = sdkVersion;
saveTelemetryState(state);
capturePosthogEvent("Installed SDK", {
version: sdkVersion,
language: sdkName,
});
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At @blaxel/core/src/common/posthog.ts, line 204:

<issue>
The version is saved to disk before the fire-and-forget fetch resolves. A silent network failure permanently suppresses retries for that version.
</issue>

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 is intentional optimistic dedup — the same pattern used by all three repos (toolkit, sdk-typescript, sdk-python). The tradeoff is accepted: worst case on network failure is one missed event per version, which is far preferable to the alternative of spamming PostHog on every SDK load if we defer the state write. Note that the bot's own suggested fix preserves the exact same save-before-fire ordering.

}
Loading