Skip to content

Commit

Permalink
Node.js support (#183)
Browse files Browse the repository at this point in the history
* Correctly access FPX_ENDPOINT in a Node runtime

* Add a log statement where something is going wrong in node.js apps

* Add sample node app

* Update readme

* Fix the "always crashing" support in Node

* Remove console.log

* Use a utility to detect whether or not to serialize env

* Record env vars in node

* Add a POST route to node api

* Format sample node app

* Fix the typing of app.fetch for Node envs

* Update get started to include Node, and write a simple Platform component

* Update callout

* Stop fighting biome. Whatever.

* Update tab keys for platforms

* Use double quotes

* Clean up Platforms.astro

* Manually format like a goober

* Update client library otel package json

* Refactor how we access env vars a bit to tidy things up

* Pass the og env to the measured fetch function

* Gosh darn website linting

* Format format format

* Do not lint my examples i sorry

* Okay do not use ts extension for example code i soz

* Clean up accessing FPX_ENDPOINT

* Patch issue with logging functions
  • Loading branch information
brettimus authored Sep 2, 2024
1 parent 6958617 commit a672c43
Show file tree
Hide file tree
Showing 20 changed files with 336 additions and 34 deletions.
1 change: 1 addition & 0 deletions examples/node-api/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FPX_ENDPOINT=http://localhost:8788/v1/traces
28 changes: 28 additions & 0 deletions examples/node-api/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# dev
.yarn/
!.yarn/releases
.vscode/*
!.vscode/launch.json
!.vscode/*.code-snippets
.idea/workspace.xml
.idea/usage.statistics.xml
.idea/shelf

# deps
node_modules/

# env
.env
.env.production

# logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

# misc
.DS_Store
9 changes: 9 additions & 0 deletions examples/node-api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
```sh
pnpm install
cp .env.example .env
pnpm dev
```

```sh
open http://localhost:8787
```
16 changes: 16 additions & 0 deletions examples/node-api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "node-api",
"scripts": {
"dev": "tsx watch src/index.ts",
"debug": "tsx --inspect-brk src/index.ts"
},
"dependencies": {
"@fiberplane/hono-otel": "workspace:*",
"@hono/node-server": "^1.12.2",
"hono": "^4.5.9"
},
"devDependencies": {
"@types/node": "^20.11.17",
"tsx": "^4.7.1"
}
}
38 changes: 38 additions & 0 deletions examples/node-api/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { instrument } from "@fiberplane/hono-otel";
import { serve } from "@hono/node-server";
import { config } from "dotenv";
import { Hono } from "hono";

// Load environment variables from .env file
config();

const app = new Hono();

app.get("/", (c) => {
console.log("Hello Hono!");
return c.text("Hello Hono!");
});

app.get("/function", (c) => {
helloFunction();
console.log(helloFunction, "that was a function");
return c.text("Hello function!");
});

function helloFunction() {
console.log("Hello function!");
}

app.post("/json", async (c) => {
const body = await c.req.json();
console.log("json body", body);
return c.json({ message: "Hello Json!", body });
});

const port = 8787;
console.log(`Server is running on port http://localhost:${port}`);

serve({
fetch: instrument(app).fetch,
port,
});
14 changes: 14 additions & 0 deletions examples/node-api/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"types": [
"node"
],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
}
}
2 changes: 1 addition & 1 deletion packages/client-library-otel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"author": "Fiberplane<[email protected]>",
"type": "module",
"main": "dist/index.js",
"version": "0.1.0-beta.12",
"version": "0.2.0-beta.1",
"dependencies": {
"@opentelemetry/api": "~1.9.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.52.1",
Expand Down
7 changes: 7 additions & 0 deletions packages/client-library-otel/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/**
* Constants for the environment variables we use to configure the library.
*/
export const ENV_FPX_ENDPOINT = "FPX_ENDPOINT";
export const ENV_FPX_LOG_LEVEL = "FPX_LOG_LEVEL";
export const ENV_FPX_SERVICE_NAME = "FPX_SERVICE_NAME";

/**
* SEMATTRS_* are constants that should actually be exposed by the Samantic Conventions package
* but are not.
Expand Down
26 changes: 15 additions & 11 deletions packages/client-library-otel/src/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { SEMRESATTRS_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
import type { ExecutionContext } from "hono";
// TODO figure out we can use something else
import { AsyncLocalStorageContextManager } from "./async-hooks";
import {
ENV_FPX_ENDPOINT,
ENV_FPX_LOG_LEVEL,
ENV_FPX_SERVICE_NAME,
} from "./constants";
import { getLogger } from "./logger";
import { measure } from "./measure";
import {
Expand All @@ -21,6 +26,7 @@ import { propagateFpxTraceId } from "./propagation";
import { isRouteInspectorRequest, respondWithRoutes } from "./routes";
import type { HonoLikeApp, HonoLikeEnv, HonoLikeFetch } from "./types";
import {
getFromEnv,
getRequestAttributes,
getResponseAttributes,
getRootRequestAttributes,
Expand Down Expand Up @@ -81,7 +87,7 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) {
request: Request,
// Name this "rawEnv" because we coerce it below into something that's easier to work with
rawEnv: HonoLikeEnv,
executionContext: ExecutionContext | undefined,
executionContext?: ExecutionContext,
) {
// Merge the default config with the user's config
const {
Expand All @@ -101,13 +107,14 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) {
// NOTE - We do *not* want to have a default for the FPX_ENDPOINT,
// so that people won't accidentally deploy to production with our middleware and
// start sending data to the default url.
const endpoint =
typeof env === "object" && env !== null ? env.FPX_ENDPOINT : null;
const endpoint = getFromEnv(env, ENV_FPX_ENDPOINT);
const isEnabled = !!endpoint && typeof endpoint === "string";

const FPX_LOG_LEVEL = libraryDebugMode ? "debug" : env?.FPX_LOG_LEVEL;
const FPX_LOG_LEVEL = libraryDebugMode
? "debug"
: getFromEnv(env, ENV_FPX_LOG_LEVEL);
const logger = getLogger(FPX_LOG_LEVEL);
// NOTE - This should only log if the FPX_LOG_LEVEL is debug
// NOTE - This should only log if the FPX_LOG_LEVEL is "debug"
logger.debug("Library debug mode is enabled");

if (!isEnabled) {
Expand All @@ -123,7 +130,8 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) {
return respondWithRoutes(webStandardFetch, endpoint, app);
}

const serviceName = env?.FPX_SERVICE_NAME ?? "unknown";
const serviceName =
getFromEnv(env, ENV_FPX_SERVICE_NAME) ?? "unknown";

// Patch all functions we want to monitor in the runtime
if (monitorCfBindings) {
Expand Down Expand Up @@ -218,11 +226,7 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) {

try {
return await context.with(activeContext, async () => {
return await measuredFetch(
newRequest,
env as HonoLikeEnv,
proxyExecutionCtx,
);
return await measuredFetch(newRequest, rawEnv, proxyExecutionCtx);
});
} finally {
// Make sure all promises are resolved before sending data to the server
Expand Down
6 changes: 6 additions & 0 deletions packages/client-library-otel/src/patch/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,11 @@ function transformLogMessage(message: unknown) {
return errorToJson(message);
}

// NOTE - Functions are not serializable, so we stringify them.
// Otherwise, we could end up with a `null` value in the attributes!
if (typeof message === "function") {
return message?.toString() ?? "";
}

return message;
}
2 changes: 1 addition & 1 deletion packages/client-library-otel/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export type HonoResponse = Awaited<HonoFetchResult>;
export type HonoLikeFetch = (
request: Request,
env: HonoLikeEnv,
executionContext: ExecutionContext | undefined,
executionContext?: ExecutionContext,
) => HonoFetchResult;
// type HonoLikeFetch = Hono["fetch"];

Expand Down
51 changes: 51 additions & 0 deletions packages/client-library-otel/src/utils/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* In Hono-node environments, env vars are not available on the `env` object that's passed to `app.fetch`.
* This helper will also check process.env and fallback to that if the env var is not present on the `env` object.
*/
export function getFromEnv(honoEnv: unknown, key: string) {
const env = getNodeSafeEnv(honoEnv);

return typeof env === "object" && env !== null
? (env as Record<string, string | null>)?.[key]
: null;
}

/**
* Return `process.env` if we're in Node.js, otherwise `honoEnv`
*
* Used to get the env object for accessing and recording env vars.
* This eixsts because in Node.js, the `env` object passed to `app.fetch` is different from the env object in other runtimes.
*
* @param honoEnv - The env object from the `app.fetch` method.
* @returns - `process.env` if we're in Node.js, otherwise `honoEnv`.
*/
export function getNodeSafeEnv(honoEnv: unknown) {
const hasProcessEnv = runtimeHasProcessEnv();
const isRunningInHonoNode = isHonoNodeEnv(honoEnv);
return hasProcessEnv && isRunningInHonoNode ? process.env : honoEnv;
}

function runtimeHasProcessEnv() {
if (typeof process !== "undefined" && typeof process.env !== "undefined") {
return true;
}
return false;
}

/**
* Helper to determine if the env is coming from a Hono node environment.
*
* In Node.js, the `env` passed to `app.fetch` is an object with keys "incoming" and "outgoing",
* one of which has circular references. We don't want to serialize this.
*/
function isHonoNodeEnv(env: unknown) {
if (typeof env !== "object" || env === null) {
return false;
}
const envKeys = Object.keys(env).map((key) => key.toLowerCase());
return (
envKeys.length === 2 &&
envKeys.includes("incoming") &&
envKeys.includes("outgoing")
);
}
1 change: 1 addition & 0 deletions packages/client-library-otel/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { getFromEnv } from "./env";
export * from "./errors";
export * from "./json";
export * from "./request";
Expand Down
25 changes: 19 additions & 6 deletions packages/client-library-otel/src/utils/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import type {
InitParam,
InputParam,
} from "../types";
import { getNodeSafeEnv } from "./env";
import { safelySerializeJSON } from "./json";

// There are so many different types of headers
// and we want to support all of them so we can
Expand All @@ -40,13 +42,24 @@ export function headersToObject(headers: PossibleHeaders) {
}

/**
* HELPER
* Helper to get the request attributes for the root request.
*
* Requires that we have a cloned request, so we can get the body and headers
* without consuming the original request.
*/
export async function getRootRequestAttributes(request: Request, env: unknown) {
let attributes: Attributes = {
// NOTE - We should not do this in production
[FPX_REQUEST_ENV]: JSON.stringify(env),
};
export async function getRootRequestAttributes(
request: Request,
honoEnv: unknown,
) {
let attributes: Attributes = {};

// HACK - We need to account for the fact that the Hono `env` is different across runtimes
// If process.env is available, we use that, otherwise we use the `env` object from the Hono runtime
const env = getNodeSafeEnv(honoEnv);
if (env) {
// NOTE - We should not *ever* do this in production
attributes[FPX_REQUEST_ENV] = safelySerializeJSON(env);
}

if (request.body) {
const bodyAttr = await formatRootRequestBody(request);
Expand Down
29 changes: 29 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion www/biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
"$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
"extends": ["../biome.jsonc"],
"files": {
"ignore": ["dist", "node_modules"]
"ignore": ["dist", "node_modules", "src/*.ts.example"]
}
}
Loading

0 comments on commit a672c43

Please sign in to comment.