Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Honeycomb tracing #21

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
2,909 changes: 759 additions & 2,150 deletions package-lock.json

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,14 @@
"build": "npm run build:firefox && npm run build:chrome"
},
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-zone": "^1.25.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.52.0",
"@opentelemetry/resources": "^1.25.0",
"@opentelemetry/sdk-trace-web": "^1.25.0",
"@types/chrome": "^0.0.268",
"@types/lodash": "^4.17.5",
"@typescript-eslint/eslint-plugin": "^7.12.0",
"@typescript-eslint/eslint-plugin": "^7.13.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-import": "^2.29.1",
Expand All @@ -29,15 +34,16 @@
"react-error-boundary": "^4.0.13",
"swr": "^2.2.5",
"tailwindcss": "^3.4.4",
"ulidx": "^2.3.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/webextension-polyfill": "^0.10.7",
"@vitejs/plugin-react": "^4.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.4.5",
"vite": "^5.2.13",
"vite": "^5.3.0",
"vite-plugin-web-extension": "^4.1.4",
"vite-plugin-zip-pack": "^1.2.3",
"webextension-polyfill": "^0.12.0"
Expand Down
65 changes: 65 additions & 0 deletions scripts/obfuscate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Obfuscate a secret string using a key string.
* @param secret The secret string to obfuscate.
* @param key The key string used for obfuscation.
* @param rotNum The number of rotations to apply (defaults to 13).
* @returns The obfuscated string.
*/
async function obfuscateString(
secret: string,
key: string,
rotNum = 13,
): Promise<string> {
const keyHash = await hashString(key);
const xored = xorStrings(secret, keyHash);
const rotated = rotateString(xored, rotNum);
return btoa(rotated);
}

/**
* Hash a string using the Web Crypto API.
* @param str The string to hash.
* @returns The hash of the string as a hexadecimal string.
*/
async function hashString(str: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const hash = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}

/**
* XOR two strings of the same length.
* @param str1 The first string.
* @param str2 The second string.
* @returns The result of XORing the two strings.
*/
function xorStrings(str1: string, str2: string): string {
const result = [];
for (let i = 0; i < str1.length; i++) {
// @ts-expect-error Not sure why TypeScript doesn't like this, but I have a headache and this works
result.push(String.fromCharCode(str1.charCodeAt(i) ^ str2.charCodeAt(i)));
}
return result.join("");
}

/**
* Rotate a string by a given number of positions (positive or negative).
* @param str The string to rotate.
* @param n The number of positions to rotate (positive for left rotation, negative for right rotation).
* @returns The rotated string.
*/
function rotateString(str: string, n: number): string {
const chars = str.split("");
n = n % chars.length;
if (n < 0) n += chars.length;
return chars.slice(n).concat(chars.slice(0, n)).join("");
}

const secret = process.env.HONEYCOMB_KEY;
const key = process.env.VITE_OBFUSCATION_SECRET;
const rot = process.env.VITE_OBFUSCATION_ROT;

obfuscateString(secret!, key!, Number(rot!)).then(console.log);
4 changes: 4 additions & 0 deletions src/background.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { getSupportedIntegration } from "@lib/integrations";
import { UpdateMessage } from "@lib/utils/messages";
import { trace } from "@opentelemetry/api";
import { ulid } from "ulidx";
import browser from "webextension-polyfill";
import { z } from "zod";
import { setupObservability, span } from "./background/telemetry";

browser.runtime.onInstalled.addListener((details) => {
console.log("Extension installed:", details);
Expand Down
59 changes: 59 additions & 0 deletions src/background/deobfuscate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Deobfuscate an obfuscated string using the key string.
* @param obfuscatedStr The obfuscated string to deobfuscate.
* @param key The key string used for obfuscation.
* @param rotNum The number of rotations applied during obfuscation (defaults to 13).
* @returns The deobfuscated (original) string.
*/
export async function deobfuscateString(
obfuscatedStr: string,
key: string,
rotNum = 13,
): Promise<string> {
const decoded = atob(obfuscatedStr);
const unrotated = rotateString(decoded, -rotNum);
const keyHash = await hashString(key);
const deobfuscated = xorStrings(unrotated, keyHash);
return deobfuscated;
}

/**
* Hash a string using the Web Crypto API.
* @param str The string to hash.
* @returns The hash of the string as a hexadecimal string.
*/
async function hashString(str: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const hash = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}

/**
* XOR two strings of the same length.
* @param str1 The first string.
* @param str2 The second string.
* @returns The result of XORing the two strings.
*/
function xorStrings(str1: string, str2: string): string {
const result = [];
for (let i = 0; i < str1.length; i++) {
result.push(String.fromCharCode(str1.charCodeAt(i) ^ str2.charCodeAt(i)));
}
return result.join("");
}

/**
* Rotate a string by a given number of positions (positive or negative).
* @param str The string to rotate.
* @param n The number of positions to rotate (positive for left rotation, negative for right rotation).
* @returns The rotated string.
*/
function rotateString(str: string, n: number): string {
const chars = str.split("");
n = n % chars.length;
if (n < 0) n += chars.length;
return chars.slice(n).concat(chars.slice(0, n)).join("");
}
21 changes: 21 additions & 0 deletions src/background/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import browser from "webextension-polyfill";

export class Storage<T> {
constructor(
private key: string,
private validator: (v: unknown) => T | Promise<T>,
) {}

async get(): Promise<T | undefined | null> {
try {
const result = await browser.storage.sync.get(this.key);
return await this.validator(result[this.key]);
} catch (error) {
return null;
}
}

async set(value: T): Promise<void> {
await browser.storage.sync.set({ [this.key]: value });
}
}
131 changes: 131 additions & 0 deletions src/background/telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import {
BatchSpanProcessor,
ConsoleSpanExporter,
SimpleSpanProcessor,
SpanExporter,
WebTracerProvider,
} from "@opentelemetry/sdk-trace-web";
import { ZoneContextManager } from "@opentelemetry/context-zone";
import { Span, trace } from "@opentelemetry/api";
import { Resource } from "@opentelemetry/resources";
import { ulid } from "ulidx";
import { z } from "zod";
import { Storage } from "./storage";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { deobfuscateString } from "./deobfuscate";

const ObservabilityConsent = z
.object({
consent: z.literal(false),
})
.or(
z.object({
consent: z.literal(true),
canTrackUserId: z.boolean(),
}),
);

const ObservabilityUserId = z.string();

const observabilityConsent = new Storage(
"consent.observability",
ObservabilityConsent.parseAsync,
);
const observabilityUserId = new Storage(
"observability.user-id",
ObservabilityUserId.parseAsync,
);

async function getObservabilityUserId() {
let userId = await observabilityUserId.get();
if (!userId) {
userId = ulid();
await observabilityUserId.set(userId);
}
return userId;
}

function deobfuscateHoneycombKey() {
// Don't read, secret! 🙈
//
// Okay just kidding. I know this is not encrypted or anything. I'm just
// throwing in some obfuscation so automated tools scanning extensions can't
// just pick up the honeycomb secret. This secret is okay to expose.
return deobfuscateString(
import.meta.env.VITE_OBFUSCATED_HONEYCOMB_SECRET,
import.meta.env.VITE_OBFUSCATION_SECRET,
Number(import.meta.env.VITE_OBFUSCATION_ROT),
);
}

export async function setupObservability() {
const consent = await observabilityConsent.get();
let userId = "no-consent";
if (consent?.consent && consent.canTrackUserId) {
console.info("User ID observability consent given.");
userId = await getObservabilityUserId();
} else {
console.info("No observability consent for user ID tracking.");
}

const session = ulid();
const provider = new WebTracerProvider({
resource: new Resource({
version: APP_VERSION,
build: BROWSER,
session,
userId,
}),
});

//
// So, I have to shelve this for now. Because the OTLPTraceExporter requires
// XHR, which is not available in service workers. They have plans for fetch
// support, but it hasn't materialized yet.
//

let exporter: SpanExporter = new ConsoleSpanExporter();
if (true || consent?.consent) {
const apiKey = await deobfuscateHoneycombKey();
exporter = new OTLPTraceExporter({
concurrencyLimit: 1,
timeoutMillis: 10_000,
url: "https://api.honeycomb.io/v1/traces",
headers: {
"x-honeycomb-team": apiKey,
},
});
}

const spanProcessor =
NODE_ENV === "development"
? new SimpleSpanProcessor(exporter)
: new BatchSpanProcessor(exporter);

provider.addSpanProcessor(spanProcessor);
provider.register({
// Changing default contextManager to use ZoneContextManager - supports asynchronous operations - optional
contextManager: new ZoneContextManager(),
});
}

export function span<T>(name: string, fn: (span: Span) => T): T;
export function span<T>(
name: string,
fn: (span: Span) => Promise<T>,
): Promise<T>;
export function span<T>(
name: string,
fn: (span: Span) => T | Promise<T>,
): T | Promise<T> {
const tracer = trace.getTracer("devpod-ext", APP_VERSION);
return tracer.startActiveSpan(name, (span) => {
const result = fn(span);
if (result instanceof Promise) {
return result.finally(() => span.end());
} else {
span.end();
return result;
}
});
}
2 changes: 1 addition & 1 deletion src/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"{{chrome}}.service_worker": "src/background.ts",
"{{firefox}}.scripts": ["src/background.ts"]
},
"permissions": ["webNavigation", "activeTab"],
"permissions": ["webNavigation", "activeTab", "storage"],
"content_scripts": [
{
"matches": ["https://github.com/*", "https://gitlab.com/*"],
Expand Down
1 change: 1 addition & 0 deletions src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
declare const APP_VERSION: string;
declare const PREVIEW: boolean;
declare const NODE_ENV: "development" | "production";
declare const BROWSER: "chrome" | "firefox";
4 changes: 4 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ function generateManifest() {

// https://vitejs.dev/config/
export default defineConfig({
build: {
minify: process.env.NODE_ENV === "production" ? "esbuild" : false,
},
resolve: {
alias: {
"@pages": path.resolve(__dirname, "src", "pages"),
Expand All @@ -44,5 +47,6 @@ export default defineConfig({
APP_VERSION: JSON.stringify(pkg.version),
PREVIEW: JSON.stringify(process.env.PREVIEW === "true"),
NODE_ENV: JSON.stringify(process.env.NODE_ENV),
BROWSER: JSON.stringify(process.env.BROWSER ?? "chrome"),
},
});