From f892c8da46a0d21de5ed588f814189800ae7edef Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Mon, 26 Aug 2024 18:09:18 +0200 Subject: [PATCH 01/25] Prototype proxying cloudflare bindings (AI and R2) --- .../client-library-otel/src/cf-bindings.ts | 140 ++++++++++++++++++ .../src/instrumentation.ts | 22 ++- 2 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 packages/client-library-otel/src/cf-bindings.ts diff --git a/packages/client-library-otel/src/cf-bindings.ts b/packages/client-library-otel/src/cf-bindings.ts new file mode 100644 index 000000000..0cc8ea370 --- /dev/null +++ b/packages/client-library-otel/src/cf-bindings.ts @@ -0,0 +1,140 @@ +import { measure } from "./measure"; + +// TODO - Can we use a Symbol here instead? +const IS_PROXIED_KEY = "__fpx_proxied"; + +export function isCloudflareAiBinding(o: unknown) { + const constructorName = getConstructorName(o); + if (constructorName !== "Ai") { + return false; + } + + // TODO - Edge case, also check for `fetcher` and other known properties on this binding, in case the user is using another class named Ai (?) + return true; +} + +export function isCloudflareR2Binding(o: unknown) { + const constructorName = getConstructorName(o); + if (constructorName !== "R2Bucket") { + return false; + } + + return true; +} + +// TODO - Remove this, it is temporary +function isCloudflareBinding(o: unknown): o is object { + return isCloudflareAiBinding(o) || isCloudflareR2Binding(o); +} + +/** + * Proxy a Cloudflare binding to add instrumentation. + * For now, just wraps all functions on the binding to use a measured version of the function. + * + * For R2, we could specifically proxy and add smarts for: + * - get + * - list + * - head + * - put + * - delete + * - createMultipartUpload + * - resumeMultipartUpload + * + * @param o - The binding to proxy + * @param bindingName - The name of the binding in the environment, e.g., "Ai" or "AVATARS_BUCKET" + * + * @returns A proxied binding + */ +export function proxyCloudflareBinding(o: unknown, bindingName: string) { + // HACK - This makes typescript happier about proxying the object + if (!o || typeof o !== "object") { + return o; + } + + if (!isCloudflareBinding(o)) { + return o; + } + + if (isAlreadyProxied(o)) { + return o; + } + + const proxiedBinding = new Proxy(o, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + + // TODO - How do we use the arguments to the function to create attributes on the span? + if (typeof value === "function") { + const methodName = String(prop); + // OPTIMIZE - Do we want to do these lookups / this wrapping every time the property is accessed? + const bindingType = getConstructorName(target); + const name = `${bindingType}.${bindingName}.${methodName}`; + const measuredBinding = measure( + { + name, + attributes: { + "cloudflare.binding.method": methodName, + "cloudflare.binding.name": bindingName, + "cloudflare.binding.type": bindingType, + }, + // OPTIMIZE - bind is expensive, can we avoid it? + }, + value.bind(target), + ); + return measuredBinding; + } + + return value; + }, + }); + + // We need to mark the binding as proxied so that we don't proxy it again in the future, + // since Workers can re-use env vars across requests. + markAsProxied(proxiedBinding); + + return proxiedBinding; +} + +/** + * Get the constructor name of an object + * Helps us detect Cloudflare bindings + * + * @param o - The object to get the constructor name of + * @returns The constructor name + * + * Example: + * ```ts + * const o = new Ai(); + * getConstructorName(o); // "Ai" + * ``` + */ +function getConstructorName(o: unknown) { + return Object.getPrototypeOf(o).constructor.name; +} + +/** + * Check if a Cloudflare binding is already proxied by us + * + * @param o - The binding to check + * @returns `true` if the binding is already proxied, `false` otherwise + */ +function isAlreadyProxied(o: object) { + if (IS_PROXIED_KEY in o) { + return !!o[IS_PROXIED_KEY]; + } + + return false; +} + +/** + * Mark a Cloudflare binding as proxied by us, so that we don't proxy it again + * + * @param o - The binding to mark + */ +function markAsProxied(o: object) { + Object.defineProperty(o, IS_PROXIED_KEY, { + value: true, + writable: true, + configurable: true, + }); +} diff --git a/packages/client-library-otel/src/instrumentation.ts b/packages/client-library-otel/src/instrumentation.ts index 08d674ec5..0f40a4205 100644 --- a/packages/client-library-otel/src/instrumentation.ts +++ b/packages/client-library-otel/src/instrumentation.ts @@ -9,6 +9,7 @@ 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 { proxyCloudflareBinding } from "./cf-bindings"; import { measure } from "./measure"; import { patchConsole, patchFetch, patchWaitUntil } from "./patch"; import { propagateFpxTraceId } from "./propagation"; @@ -25,6 +26,8 @@ type FpxConfig = { /** Send data to FPX about each fetch call made during a handler's lifetime */ fetch: boolean; logging: boolean; + /** Proxy Cloudflare bindings to add instrumentation */ + cfBindings: boolean; }; }; @@ -40,6 +43,7 @@ const defaultConfig = { monitor: { fetch: true, logging: true, + cfBindings: true, }, }; @@ -57,7 +61,7 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) { env: HonoLikeEnv, executionContext: ExecutionContext | undefined, ) { - // NOTE - We used to have a handy default for the fpx endpoint, but we need to remove that, + // 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 = @@ -81,8 +85,22 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) { // Patch the related functions to monitor const { - monitor: { fetch: monitorFetch, logging: monitorLogging }, + monitor: { + fetch: monitorFetch, + logging: monitorLogging, + cfBindings: monitorCfBindings, + }, } = mergeConfigs(defaultConfig, config); + if (monitorCfBindings) { + const envKeys = env ? Object.keys(env) : []; + for (const bindingName of envKeys) { + // @ts-expect-error - We know that env is a Record + env[bindingName] = proxyCloudflareBinding( + env[bindingName], + bindingName, + ); + } + } if (monitorLogging) { patchConsole(); } From bf9cd6f62fdfdbad4a011e4404825b614a36307c Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Mon, 26 Aug 2024 18:11:17 +0200 Subject: [PATCH 02/25] Update comment on measuredBinding --- packages/client-library-otel/src/cf-bindings.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/client-library-otel/src/cf-bindings.ts b/packages/client-library-otel/src/cf-bindings.ts index 0cc8ea370..711a9c7ad 100644 --- a/packages/client-library-otel/src/cf-bindings.ts +++ b/packages/client-library-otel/src/cf-bindings.ts @@ -63,7 +63,6 @@ export function proxyCloudflareBinding(o: unknown, bindingName: string) { get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver); - // TODO - How do we use the arguments to the function to create attributes on the span? if (typeof value === "function") { const methodName = String(prop); // OPTIMIZE - Do we want to do these lookups / this wrapping every time the property is accessed? @@ -77,8 +76,13 @@ export function proxyCloudflareBinding(o: unknown, bindingName: string) { "cloudflare.binding.name": bindingName, "cloudflare.binding.type": bindingType, }, - // OPTIMIZE - bind is expensive, can we avoid it? + // TODO - Use these three callbacks to add additional attributes to the span + // + // onStart: (span, args) => {}, + // onSuccess: (span, result) => {}, + // onError: (span, error) => {}, }, + // OPTIMIZE - bind is expensive, can we avoid it? value.bind(target), ); return measuredBinding; From dea79fd2667454dcb6e174713eba6e0b380111b8 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Mon, 26 Aug 2024 18:12:30 +0200 Subject: [PATCH 03/25] Fix typescript error --- packages/client-library-otel/src/instrumentation.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client-library-otel/src/instrumentation.ts b/packages/client-library-otel/src/instrumentation.ts index 0f40a4205..03924bf2e 100644 --- a/packages/client-library-otel/src/instrumentation.ts +++ b/packages/client-library-otel/src/instrumentation.ts @@ -96,6 +96,7 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) { for (const bindingName of envKeys) { // @ts-expect-error - We know that env is a Record env[bindingName] = proxyCloudflareBinding( + // @ts-expect-error - We know that env is a Record env[bindingName], bindingName, ); From 470a99adf93e28fb08a1b65f4b3fbe69f1fb9534 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 27 Aug 2024 11:24:26 +0200 Subject: [PATCH 04/25] Add goosify sample app with every cf binding imaginable --- .../client-library-otel/src/cf-bindings.ts | 1 + pnpm-lock.yaml | 204 +++++++++++++++++- sample-apps/goose-quotes/package.json | 4 +- sample-apps/goosify/.dev.vars.example | 3 + sample-apps/goosify/.gitignore | 34 +++ sample-apps/goosify/README.md | 8 + sample-apps/goosify/drizzle.config.ts | 16 ++ sample-apps/goosify/package.json | 16 ++ sample-apps/goosify/src/index.ts | 16 ++ sample-apps/goosify/tsconfig.json | 17 ++ sample-apps/goosify/wrangler.toml | 19 ++ 11 files changed, 326 insertions(+), 12 deletions(-) create mode 100644 sample-apps/goosify/.dev.vars.example create mode 100644 sample-apps/goosify/.gitignore create mode 100644 sample-apps/goosify/README.md create mode 100644 sample-apps/goosify/drizzle.config.ts create mode 100644 sample-apps/goosify/package.json create mode 100644 sample-apps/goosify/src/index.ts create mode 100644 sample-apps/goosify/tsconfig.json create mode 100644 sample-apps/goosify/wrangler.toml diff --git a/packages/client-library-otel/src/cf-bindings.ts b/packages/client-library-otel/src/cf-bindings.ts index 711a9c7ad..04d0a422d 100644 --- a/packages/client-library-otel/src/cf-bindings.ts +++ b/packages/client-library-otel/src/cf-bindings.ts @@ -19,6 +19,7 @@ export function isCloudflareR2Binding(o: unknown) { return false; } + // TODO - Edge case, also check for `list`, `delete`, and other known methods on this binding, in case the user is using another class named R2Bucket (?) return true; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85021b588..5ed60bfe2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: version: 5.5.4 wrangler: specifier: ^3.62.0 - version: 3.70.0(@cloudflare/workers-types@4.20240806.0) + version: 3.70.0(@cloudflare/workers-types@4.20240821.1) api: dependencies: @@ -192,7 +192,7 @@ importers: version: 16.4.5 drizzle-orm: specifier: ^0.32.0 - version: 0.32.2(@cloudflare/workers-types@4.20240806.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1) + version: 0.32.2(@cloudflare/workers-types@4.20240821.1)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1) hono: specifier: ^4.5.1 version: 4.5.5 @@ -201,14 +201,33 @@ importers: version: 4.55.4(encoding@0.1.13)(zod@3.23.8) devDependencies: '@cloudflare/workers-types': - specifier: ^4.20240529.0 - version: 4.20240806.0 + specifier: ^4.20240821.1 + version: 4.20240821.1 drizzle-kit: specifier: ^0.23.0 version: 0.23.2 wrangler: - specifier: ^3.67.1 - version: 3.70.0(@cloudflare/workers-types@4.20240806.0) + specifier: ^3.72.2 + version: 3.72.2(@cloudflare/workers-types@4.20240821.1) + + sample-apps/goosify: + dependencies: + '@fiberplane/hono-otel': + specifier: workspace:* + version: link:../../packages/client-library-otel + dotenv: + specifier: ^16.4.5 + version: 16.4.5 + hono: + specifier: ^4.5.9 + version: 4.5.9 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20240821.1 + version: 4.20240821.1 + wrangler: + specifier: ^3.72.2 + version: 3.72.2(@cloudflare/workers-types@4.20240821.1) studio: dependencies: @@ -410,7 +429,7 @@ importers: version: 1.6.0(@types/node@20.14.15) wrangler: specifier: ^3.57.0 - version: 3.70.0(@cloudflare/workers-types@4.20240806.0) + version: 3.70.0(@cloudflare/workers-types@4.20240821.1) webhonc: dependencies: @@ -481,7 +500,7 @@ importers: version: 0.14.1 wrangler: specifier: ^3.70.0 - version: 3.70.0(@cloudflare/workers-types@4.20240806.0) + version: 3.70.0(@cloudflare/workers-types@4.20240821.1) packages: @@ -703,36 +722,73 @@ packages: cpu: [x64] os: [darwin] + '@cloudflare/workerd-darwin-64@1.20240821.1': + resolution: {integrity: sha512-CDBpfZKrSy4YrIdqS84z67r3Tzal2pOhjCsIb63IuCnvVes59/ft1qhczBzk9EffeOE2iTCrA4YBT7Sbn7USew==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20240806.0': resolution: {integrity: sha512-8c3KvmzYp/wg+82KHSOzDetJK+pThH4MTrU1OsjmsR2cUfedm5dk5Lah9/0Ld68+6A0umFACi4W2xJHs/RoBpA==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20240821.1': + resolution: {integrity: sha512-Q+9RedvNbPcEt/dKni1oN94OxbvuNAeJkgHmrLFTGF8zu21wzOhVkQeRNxcYxrMa9mfStc457NAg13OVCj2kHQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + '@cloudflare/workerd-linux-64@1.20240806.0': resolution: {integrity: sha512-/149Bpxw4e2p5QqnBc06g0mx+4sZYh9j0doilnt0wk/uqYkLp0DdXGMQVRB74sBLg2UD3wW8amn1w3KyFhK2tQ==} engines: {node: '>=16'} cpu: [x64] os: [linux] + '@cloudflare/workerd-linux-64@1.20240821.1': + resolution: {integrity: sha512-j6z3KsPtawrscoLuP985LbqFrmsJL6q1mvSXOXTqXGODAHIzGBipHARdOjms3UQqovzvqB2lQaQsZtLBwCZxtA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + '@cloudflare/workerd-linux-arm64@1.20240806.0': resolution: {integrity: sha512-lacDWY3S1rKL/xT6iMtTQJEKmTTKrBavPczInEuBFXElmrS6IwVjZwv8hhVm32piyNt/AuFu9BYoJALi9D85/g==} engines: {node: '>=16'} cpu: [arm64] os: [linux] + '@cloudflare/workerd-linux-arm64@1.20240821.1': + resolution: {integrity: sha512-I9bHgZOxJQW0CV5gTdilyxzTG7ILzbTirehQWgfPx9X77E/7eIbR9sboOMgyeC69W4he0SKtpx0sYZuTJu4ERw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + '@cloudflare/workerd-windows-64@1.20240806.0': resolution: {integrity: sha512-hC6JEfTSQK6//Lg+D54TLVn1ceTPY+fv4MXqDZIYlPP53iN+dL8Xd0utn2SG57UYdlL5FRAhm/EWHcATZg1RgA==} engines: {node: '>=16'} cpu: [x64] os: [win32] + '@cloudflare/workerd-windows-64@1.20240821.1': + resolution: {integrity: sha512-keC97QPArs6LWbPejQM7/Y8Jy8QqyaZow4/ZdsGo+QjlOLiZRDpAenfZx3CBUoWwEeFwQTl2FLO+8hV1SWFFYw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@cloudflare/workers-shared@0.1.0': resolution: {integrity: sha512-SyD4iw6jM4anZaG+ujgVETV4fulF2KHBOW31eavbVN7TNpk2l4aJgwY1YSPK00IKSWsoQuH2TigR446KuT5lqQ==} + '@cloudflare/workers-shared@0.3.0': + resolution: {integrity: sha512-cqtLW1QiBC/ABaZIhAdyGCsnHHY6pAb6hsVUZg82Co2gQtf/faxRYV1FgpCwUYroTdk6A66xUMSTmFqreKCJow==} + engines: {node: '>=16.7.0'} + '@cloudflare/workers-types@4.20240806.0': resolution: {integrity: sha512-8lvgrwXGTZEBsUQJ8YUnMk72Anh9omwr6fqWLw/EwVgcw1nQxs/bfdadBEbdP48l9fWXjE4E5XERLUrrFuEpsg==} + '@cloudflare/workers-types@4.20240821.1': + resolution: {integrity: sha512-icAkbnAqgVl6ef9lgLTom8na+kj2RBw2ViPAQ586hbdj0xZcnrjK7P46Eu08OU9D/lNDgN2sKU/sxhe2iK/gIg==} + '@codemirror/autocomplete@6.18.0': resolution: {integrity: sha512-5DbOvBbY4qW5l57cjDsmmpDh3/TeK1vXfTHa+BUMrRzdWdcxKZ4U4V7vQaTtOpApNU4kLS4FQ6cINtLg245LXA==} peerDependencies: @@ -4529,6 +4585,10 @@ packages: resolution: {integrity: sha512-fXBXHqaVfimWofbelLXci8pZyIwBMkDIwCa4OwZvK+xVbEyYLELVP4DfbGaj1aEM6ZY3hHgs4qLvCO2ChkhgQw==} engines: {node: '>=16.0.0'} + hono@4.5.9: + resolution: {integrity: sha512-zz8ktqMDRrZETjxBrv8C5PQRFbrTRCLNVAjD1SNQyOzv4VjmX68Uxw83xQ6oxdAB60HiWnGEatiKA8V3SZLDkQ==} + engines: {node: '>=16.0.0'} + html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} @@ -5176,6 +5236,11 @@ packages: engines: {node: '>=16.13'} hasBin: true + miniflare@3.20240821.0: + resolution: {integrity: sha512-4BhLGpssQxM/O6TZmJ10GkT3wBJK6emFkZ3V87/HyvQmVt8zMxEBvyw5uv6kdtp+7F54Nw6IKFJjPUL8rFVQrQ==} + engines: {node: '>=16.13'} + hasBin: true + minimatch@10.0.1: resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} engines: {node: 20 || >=22} @@ -6888,6 +6953,11 @@ packages: engines: {node: '>=16'} hasBin: true + workerd@1.20240821.1: + resolution: {integrity: sha512-y4phjCnEG96u8ZkgkkHB+gSw0i6uMNo23rBmixylWpjxDklB+LWD8dztasvsu7xGaZbLoTxQESdEw956F7VJDA==} + engines: {node: '>=16'} + hasBin: true + wrangler@3.70.0: resolution: {integrity: sha512-aMtCEXmH02SIxbxOFGGuJ8ZemmG9W+IcNRh5D4qIKgzSxqy0mt9mRoPNPSv1geGB2/8YAyeLGPf+tB4lxz+ssg==} engines: {node: '>=16.17.0'} @@ -6898,6 +6968,16 @@ packages: '@cloudflare/workers-types': optional: true + wrangler@3.72.2: + resolution: {integrity: sha512-7nxkJ4md+KtESNJ/0DwTM7bHZP+uNRpJT5gMDT9WllP9UVzYdtXCTF+p4CHtxIReUpe6pOi7tb05hK9/Q6WaiA==} + engines: {node: '>=16.17.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20240821.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -7320,22 +7400,41 @@ snapshots: '@cloudflare/workerd-darwin-64@1.20240806.0': optional: true + '@cloudflare/workerd-darwin-64@1.20240821.1': + optional: true + '@cloudflare/workerd-darwin-arm64@1.20240806.0': optional: true + '@cloudflare/workerd-darwin-arm64@1.20240821.1': + optional: true + '@cloudflare/workerd-linux-64@1.20240806.0': optional: true + '@cloudflare/workerd-linux-64@1.20240821.1': + optional: true + '@cloudflare/workerd-linux-arm64@1.20240806.0': optional: true + '@cloudflare/workerd-linux-arm64@1.20240821.1': + optional: true + '@cloudflare/workerd-windows-64@1.20240806.0': optional: true + '@cloudflare/workerd-windows-64@1.20240821.1': + optional: true + '@cloudflare/workers-shared@0.1.0': {} + '@cloudflare/workers-shared@0.3.0': {} + '@cloudflare/workers-types@4.20240806.0': {} + '@cloudflare/workers-types@4.20240821.1': {} + '@codemirror/autocomplete@6.18.0(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.33.0)(@lezer/common@1.2.1)': dependencies: '@codemirror/language': 6.10.2 @@ -10305,9 +10404,9 @@ snapshots: '@types/react': 18.3.3 react: 18.3.1 - drizzle-orm@0.32.2(@cloudflare/workers-types@4.20240806.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1): + drizzle-orm@0.32.2(@cloudflare/workers-types@4.20240821.1)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1): optionalDependencies: - '@cloudflare/workers-types': 4.20240806.0 + '@cloudflare/workers-types': 4.20240821.1 '@libsql/client': 0.6.2 '@neondatabase/serverless': 0.9.4 '@opentelemetry/api': 1.9.0 @@ -11117,6 +11216,8 @@ snapshots: hono@4.5.5: {} + hono@4.5.9: {} + html-escaper@3.0.3: {} html-void-elements@3.0.0: {} @@ -12003,6 +12104,25 @@ snapshots: - supports-color - utf-8-validate + miniflare@3.20240821.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + acorn: 8.12.1 + acorn-walk: 8.3.3 + capnp-ts: 0.7.0 + exit-hook: 2.2.1 + glob-to-regexp: 0.4.1 + stoppable: 1.1.0 + undici: 5.28.4 + workerd: 1.20240821.1 + ws: 8.18.0 + youch: 3.3.3 + zod: 3.23.8 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + minimatch@10.0.1: dependencies: brace-expansion: 2.0.1 @@ -13882,6 +14002,14 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20240806.0 '@cloudflare/workerd-windows-64': 1.20240806.0 + workerd@1.20240821.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20240821.1 + '@cloudflare/workerd-darwin-arm64': 1.20240821.1 + '@cloudflare/workerd-linux-64': 1.20240821.1 + '@cloudflare/workerd-linux-arm64': 1.20240821.1 + '@cloudflare/workerd-windows-64': 1.20240821.1 + wrangler@3.70.0(@cloudflare/workers-types@4.20240806.0): dependencies: '@cloudflare/kv-asset-handler': 0.3.4 @@ -13910,6 +14038,62 @@ snapshots: - supports-color - utf-8-validate + wrangler@3.70.0(@cloudflare/workers-types@4.20240821.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.3.4 + '@cloudflare/workers-shared': 0.1.0 + '@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19) + '@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19) + blake3-wasm: 2.1.5 + chokidar: 3.6.0 + date-fns: 3.6.0 + esbuild: 0.17.19 + miniflare: 3.20240806.0 + nanoid: 3.3.7 + path-to-regexp: 6.2.2 + resolve: 1.22.8 + resolve.exports: 2.0.2 + selfsigned: 2.4.1 + source-map: 0.6.1 + unenv: unenv-nightly@1.10.0-1717606461.a117952 + workerd: 1.20240806.0 + xxhash-wasm: 1.0.2 + optionalDependencies: + '@cloudflare/workers-types': 4.20240821.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + wrangler@3.72.2(@cloudflare/workers-types@4.20240821.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.3.4 + '@cloudflare/workers-shared': 0.3.0 + '@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19) + '@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19) + blake3-wasm: 2.1.5 + chokidar: 3.6.0 + date-fns: 3.6.0 + esbuild: 0.17.19 + miniflare: 3.20240821.0 + nanoid: 3.3.7 + path-to-regexp: 6.2.2 + resolve: 1.22.8 + resolve.exports: 2.0.2 + selfsigned: 2.4.1 + source-map: 0.6.1 + unenv: unenv-nightly@1.10.0-1717606461.a117952 + workerd: 1.20240821.1 + xxhash-wasm: 1.0.2 + optionalDependencies: + '@cloudflare/workers-types': 4.20240821.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 diff --git a/sample-apps/goose-quotes/package.json b/sample-apps/goose-quotes/package.json index 1d951e5c2..7809d15c4 100644 --- a/sample-apps/goose-quotes/package.json +++ b/sample-apps/goose-quotes/package.json @@ -15,9 +15,9 @@ "openai": "^4.53.0" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20240529.0", + "@cloudflare/workers-types": "^4.20240821.1", "drizzle-kit": "^0.23.0", - "wrangler": "^3.67.1" + "wrangler": "^3.72.2" }, "homepage": "https://github.com/fiberplane/fpx/sample-apps/goose-quotes#readme" } diff --git a/sample-apps/goosify/.dev.vars.example b/sample-apps/goosify/.dev.vars.example new file mode 100644 index 000000000..48b001fe5 --- /dev/null +++ b/sample-apps/goosify/.dev.vars.example @@ -0,0 +1,3 @@ +CLOUDFLARE_D1_TOKEN= +CLOUDFLARE_ACCOUNT_ID= +CLOUDFLARE_DATABASE_ID= \ No newline at end of file diff --git a/sample-apps/goosify/.gitignore b/sample-apps/goosify/.gitignore new file mode 100644 index 000000000..68f4d434f --- /dev/null +++ b/sample-apps/goosify/.gitignore @@ -0,0 +1,34 @@ +# prod +dist/ + +# dev +package-lock.json +.yarn/ +!.yarn/releases +.vscode/* +!.vscode/launch.json +!.vscode/*.code-snippets +.idea/workspace.xml +.idea/usage.statistics.xml +.idea/shelf + +# deps +node_modules/ +.wrangler + +# env +.env +.env.production +.dev.vars + +# logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# misc +.DS_Store diff --git a/sample-apps/goosify/README.md b/sample-apps/goosify/README.md new file mode 100644 index 000000000..47c2ff63f --- /dev/null +++ b/sample-apps/goosify/README.md @@ -0,0 +1,8 @@ +```sh +pnpm i +pnpm dev +``` + +```sh +pnpm deploy +``` diff --git a/sample-apps/goosify/drizzle.config.ts b/sample-apps/goosify/drizzle.config.ts new file mode 100644 index 000000000..37ed8bff2 --- /dev/null +++ b/sample-apps/goosify/drizzle.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/schema.ts", + out: "./migrations", + dialect: "sqlite", + driver: "d1-http", + dbCredentials: { + // biome-ignore lint/style/noNonNullAssertion: + accountId: process.env.CLOUDFLARE_ACCOUNT_ID!, + // biome-ignore lint/style/noNonNullAssertion: + databaseId: process.env.CLOUDFLARE_DATABASE_ID!, + // biome-ignore lint/style/noNonNullAssertion: + token: process.env.CLOUDFLARE_D1_TOKEN!, + }, +}); diff --git a/sample-apps/goosify/package.json b/sample-apps/goosify/package.json new file mode 100644 index 000000000..adb0e4b89 --- /dev/null +++ b/sample-apps/goosify/package.json @@ -0,0 +1,16 @@ +{ + "name": "goosify", + "scripts": { + "dev": "wrangler dev src/index.ts", + "deploy": "wrangler deploy --minify src/index.ts" + }, + "dependencies": { + "@fiberplane/hono-otel": "workspace:*", + "dotenv": "^16.4.5", + "hono": "^4.5.9" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240821.1", + "wrangler": "^3.72.2" + } +} diff --git a/sample-apps/goosify/src/index.ts b/sample-apps/goosify/src/index.ts new file mode 100644 index 000000000..588519fd6 --- /dev/null +++ b/sample-apps/goosify/src/index.ts @@ -0,0 +1,16 @@ +import { Hono } from "hono"; + +type Bindings = { + DB: D1Database; + AI: Ai; + GOOSIFY_KV: KVNamespace; + GOOSIFY_R2: R2Bucket; +}; + +const app = new Hono<{ Bindings: Bindings }>(); + +app.get("/", (c) => { + return c.text("Hello Hono!"); +}); + +export default app; diff --git a/sample-apps/goosify/tsconfig.json b/sample-apps/goosify/tsconfig.json new file mode 100644 index 000000000..934f03cc7 --- /dev/null +++ b/sample-apps/goosify/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": [ + "ESNext" + ], + "types": [ + "@cloudflare/workers-types/2023-07-01" + ], + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx" + }, +} \ No newline at end of file diff --git a/sample-apps/goosify/wrangler.toml b/sample-apps/goosify/wrangler.toml new file mode 100644 index 000000000..93f9854ea --- /dev/null +++ b/sample-apps/goosify/wrangler.toml @@ -0,0 +1,19 @@ +name = "goosify" +compatibility_date = "2024-08-27" +compatibility_flags = [ "nodejs_compat" ] + +[[kv_namespaces]] +binding = "GOOSIFY_KV" +id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +[[r2_buckets]] +binding = "GOOSIFY_R2" +bucket_name = "goosify-bucket" + +[[d1_databases]] +binding = "DB" +database_name = "goosify" +database_id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +[ai] +binding = "AI" \ No newline at end of file From f7e438b4a121f888609cc66896e8cf5075256f12 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 27 Aug 2024 13:40:35 +0200 Subject: [PATCH 05/25] Set up local D1 with drizzle --- sample-apps/goosify/.dev.vars.example | 4 +- sample-apps/goosify/.gitignore | 1 + sample-apps/goosify/.prod.vars.example | 3 + sample-apps/goosify/README.md | 25 +++++- sample-apps/goosify/db-touch.sql | 0 sample-apps/goosify/drizzle.config.ts | 85 ++++++++++++++++--- .../migrations/0000_new_human_robot.sql | 7 ++ .../migrations/meta/0000_snapshot.json | 60 +++++++++++++ .../drizzle/migrations/meta/_journal.json | 13 +++ sample-apps/goosify/package.json | 8 +- sample-apps/goosify/src/db/schema.ts | 10 +++ sample-apps/goosify/src/index.ts | 6 ++ sample-apps/goosify/wrangler.toml | 3 +- 13 files changed, 205 insertions(+), 20 deletions(-) create mode 100644 sample-apps/goosify/.prod.vars.example create mode 100644 sample-apps/goosify/db-touch.sql create mode 100644 sample-apps/goosify/drizzle/migrations/0000_new_human_robot.sql create mode 100644 sample-apps/goosify/drizzle/migrations/meta/0000_snapshot.json create mode 100644 sample-apps/goosify/drizzle/migrations/meta/_journal.json create mode 100644 sample-apps/goosify/src/db/schema.ts diff --git a/sample-apps/goosify/.dev.vars.example b/sample-apps/goosify/.dev.vars.example index 48b001fe5..f87f5c14c 100644 --- a/sample-apps/goosify/.dev.vars.example +++ b/sample-apps/goosify/.dev.vars.example @@ -1,3 +1 @@ -CLOUDFLARE_D1_TOKEN= -CLOUDFLARE_ACCOUNT_ID= -CLOUDFLARE_DATABASE_ID= \ No newline at end of file +# TODO \ No newline at end of file diff --git a/sample-apps/goosify/.gitignore b/sample-apps/goosify/.gitignore index 68f4d434f..f2ab12025 100644 --- a/sample-apps/goosify/.gitignore +++ b/sample-apps/goosify/.gitignore @@ -1,4 +1,5 @@ # prod +.prod.vars dist/ # dev diff --git a/sample-apps/goosify/.prod.vars.example b/sample-apps/goosify/.prod.vars.example new file mode 100644 index 000000000..48b001fe5 --- /dev/null +++ b/sample-apps/goosify/.prod.vars.example @@ -0,0 +1,3 @@ +CLOUDFLARE_D1_TOKEN= +CLOUDFLARE_ACCOUNT_ID= +CLOUDFLARE_DATABASE_ID= \ No newline at end of file diff --git a/sample-apps/goosify/README.md b/sample-apps/goosify/README.md index 47c2ff63f..2e8486efe 100644 --- a/sample-apps/goosify/README.md +++ b/sample-apps/goosify/README.md @@ -1,8 +1,31 @@ +## Overviews + +This is a Cloudflare mega-app. + +It is also goose themed. + +It allows us to test instrumentation for the following CF bindings: + +- D1 +- R2 +- KV +- AI SDK + +There is some trickiness in getting Drizzle to work with a local D1, so be mindful that this setup might break. + +There is some plumbing in place to deploy to prod, but that's not what this is meant for rn. + +You'll need a CF account to run this with the AI binding. + +## Commands ```sh pnpm i pnpm dev ``` ```sh -pnpm deploy +# HACK - This script initializes a D1 database *locally* so that we can mess with it +pnpm db:touch +pnpm db:generate +pnpm db:migrate ``` diff --git a/sample-apps/goosify/db-touch.sql b/sample-apps/goosify/db-touch.sql new file mode 100644 index 000000000..e69de29bb diff --git a/sample-apps/goosify/drizzle.config.ts b/sample-apps/goosify/drizzle.config.ts index 37ed8bff2..e6364b230 100644 --- a/sample-apps/goosify/drizzle.config.ts +++ b/sample-apps/goosify/drizzle.config.ts @@ -1,16 +1,73 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { config } from "dotenv"; import { defineConfig } from "drizzle-kit"; -export default defineConfig({ - schema: "./src/schema.ts", - out: "./migrations", - dialect: "sqlite", - driver: "d1-http", - dbCredentials: { - // biome-ignore lint/style/noNonNullAssertion: - accountId: process.env.CLOUDFLARE_ACCOUNT_ID!, - // biome-ignore lint/style/noNonNullAssertion: - databaseId: process.env.CLOUDFLARE_DATABASE_ID!, - // biome-ignore lint/style/noNonNullAssertion: - token: process.env.CLOUDFLARE_D1_TOKEN!, - }, -}); +let dbConfig: ReturnType; +if (process.env.GOOSIFY_ENV === "production") { + config({ path: "./.prod.vars" }); + dbConfig= defineConfig({ + schema: "./src/db/schema.ts", + out: "./drizzle/migrations", + dialect: "sqlite", + driver: "d1-http", + dbCredentials: { + // biome-ignore lint/style/noNonNullAssertion: + accountId: process.env.CLOUDFLARE_ACCOUNT_ID!, + // biome-ignore lint/style/noNonNullAssertion: + databaseId: process.env.CLOUDFLARE_DATABASE_ID!, + // biome-ignore lint/style/noNonNullAssertion: + token: process.env.CLOUDFLARE_D1_TOKEN!, + }, + }); +} else { + config({ path: "./.dev.vars" }); + const localD1DB = getLocalD1DB(); + if (!localD1DB) { + process.exit(1); + } + + dbConfig = defineConfig({ + schema: "./src/db/schema.ts", + out: "./drizzle/migrations", + dialect: "sqlite", + dbCredentials: { + url: localD1DB, + }, + }); +} + +export default dbConfig; + +// Modified from: https://github.com/drizzle-team/drizzle-orm/discussions/1545 +function getLocalD1DB() { + try { + const basePath = path.resolve('.wrangler'); + const files = fs + .readdirSync(basePath, { encoding: 'utf-8', recursive: true }) + .filter((f) => f.endsWith('.sqlite')); + + // In case there are multiple .sqlite files, we want the most recent one. + files.sort((a, b) => { + const statA = fs.statSync(path.join(basePath, a)); + const statB = fs.statSync(path.join(basePath, b)); + return statB.mtime.getTime() - statA.mtime.getTime(); + }); + const dbFile = files[0]; + + if (!dbFile) { + throw new Error(`.sqlite file not found in ${basePath}`); + } + + const url = path.resolve(basePath, dbFile); + + return url; + } catch (err) { + if (err instanceof Error) { + console.log(`Error resolving local D1 DB: ${err.message}`); + } else { + console.log(`Error resolving local D1 DB: ${err}`); + } + } +} + diff --git a/sample-apps/goosify/drizzle/migrations/0000_new_human_robot.sql b/sample-apps/goosify/drizzle/migrations/0000_new_human_robot.sql new file mode 100644 index 000000000..cfcd2ebfb --- /dev/null +++ b/sample-apps/goosify/drizzle/migrations/0000_new_human_robot.sql @@ -0,0 +1,7 @@ +CREATE TABLE `geese` ( + `id` integer PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `avatar` text, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL +); diff --git a/sample-apps/goosify/drizzle/migrations/meta/0000_snapshot.json b/sample-apps/goosify/drizzle/migrations/meta/0000_snapshot.json new file mode 100644 index 000000000..b18165110 --- /dev/null +++ b/sample-apps/goosify/drizzle/migrations/meta/0000_snapshot.json @@ -0,0 +1,60 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7a67a8d6-2ac2-4242-948d-048e03cba163", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "geese": { + "name": "geese", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/sample-apps/goosify/drizzle/migrations/meta/_journal.json b/sample-apps/goosify/drizzle/migrations/meta/_journal.json new file mode 100644 index 000000000..9dea3f86e --- /dev/null +++ b/sample-apps/goosify/drizzle/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "6", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1724758784931, + "tag": "0000_new_human_robot", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/sample-apps/goosify/package.json b/sample-apps/goosify/package.json index adb0e4b89..868a0a8ef 100644 --- a/sample-apps/goosify/package.json +++ b/sample-apps/goosify/package.json @@ -2,7 +2,13 @@ "name": "goosify", "scripts": { "dev": "wrangler dev src/index.ts", - "deploy": "wrangler deploy --minify src/index.ts" + "deploy": "wrangler deploy --minify src/index.ts", + "db:generate": "LOCAL_DB_PATH=$(find .wrangler/state/v3/d1/miniflare-D1DatabaseObject -type f -name '*.sqlite' -print -quit) drizzle-kit generate", + "db:migrate": "wrangler d1 migrations apply goosify --local", + "db:studio": "drizzle-kit studio", + "db:touch": "wrangler d1 execute goosify --local --command='SELECT 1'", + "db:migrate:prod": "GOOSIFY_ENV=production drizzle-kit migrate", + "db:studio:prod": "GOOSIFY_ENV=production drizzle-kit studio" }, "dependencies": { "@fiberplane/hono-otel": "workspace:*", diff --git a/sample-apps/goosify/src/db/schema.ts b/sample-apps/goosify/src/db/schema.ts new file mode 100644 index 000000000..fae7b6858 --- /dev/null +++ b/sample-apps/goosify/src/db/schema.ts @@ -0,0 +1,10 @@ +import { sql } from "drizzle-orm"; +import { text, integer, sqliteTable } from "drizzle-orm/sqlite-core"; + +export const geese = sqliteTable("geese", { + id: integer("id", { mode: "number" }).primaryKey(), + name: text("name").notNull(), + avatar: text("avatar"), + createdAt: text("created_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), +}); diff --git a/sample-apps/goosify/src/index.ts b/sample-apps/goosify/src/index.ts index 588519fd6..f2966d529 100644 --- a/sample-apps/goosify/src/index.ts +++ b/sample-apps/goosify/src/index.ts @@ -13,4 +13,10 @@ app.get("/", (c) => { return c.text("Hello Hono!"); }); +app.get("/api/geese", async (c) => { + const geese = await c.env.DB.prepare("SELECT * FROM geese").all(); + return c.json({ geese }); +}); + + export default app; diff --git a/sample-apps/goosify/wrangler.toml b/sample-apps/goosify/wrangler.toml index 93f9854ea..23f5b4f4d 100644 --- a/sample-apps/goosify/wrangler.toml +++ b/sample-apps/goosify/wrangler.toml @@ -13,7 +13,8 @@ bucket_name = "goosify-bucket" [[d1_databases]] binding = "DB" database_name = "goosify" -database_id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +migrations_dir = "drizzle/migrations" [ai] binding = "AI" \ No newline at end of file From cae71935739bdad960d8e977e38cb2bf1da303a9 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 27 Aug 2024 13:50:10 +0200 Subject: [PATCH 06/25] Return geese from goosify (and format code) --- sample-apps/goosify/drizzle.config.ts | 13 ++++++------- sample-apps/goosify/package.json | 2 +- sample-apps/goosify/src/db/schema.ts | 2 +- sample-apps/goosify/src/index.ts | 8 ++++++-- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/sample-apps/goosify/drizzle.config.ts b/sample-apps/goosify/drizzle.config.ts index e6364b230..b8fc80b8b 100644 --- a/sample-apps/goosify/drizzle.config.ts +++ b/sample-apps/goosify/drizzle.config.ts @@ -1,12 +1,12 @@ -import fs from 'node:fs'; -import path from 'node:path'; +import fs from "node:fs"; +import path from "node:path"; import { config } from "dotenv"; import { defineConfig } from "drizzle-kit"; let dbConfig: ReturnType; if (process.env.GOOSIFY_ENV === "production") { config({ path: "./.prod.vars" }); - dbConfig= defineConfig({ + dbConfig = defineConfig({ schema: "./src/db/schema.ts", out: "./drizzle/migrations", dialect: "sqlite", @@ -42,10 +42,10 @@ export default dbConfig; // Modified from: https://github.com/drizzle-team/drizzle-orm/discussions/1545 function getLocalD1DB() { try { - const basePath = path.resolve('.wrangler'); + const basePath = path.resolve(".wrangler"); const files = fs - .readdirSync(basePath, { encoding: 'utf-8', recursive: true }) - .filter((f) => f.endsWith('.sqlite')); + .readdirSync(basePath, { encoding: "utf-8", recursive: true }) + .filter((f) => f.endsWith(".sqlite")); // In case there are multiple .sqlite files, we want the most recent one. files.sort((a, b) => { @@ -70,4 +70,3 @@ function getLocalD1DB() { } } } - diff --git a/sample-apps/goosify/package.json b/sample-apps/goosify/package.json index 868a0a8ef..437133636 100644 --- a/sample-apps/goosify/package.json +++ b/sample-apps/goosify/package.json @@ -3,7 +3,7 @@ "scripts": { "dev": "wrangler dev src/index.ts", "deploy": "wrangler deploy --minify src/index.ts", - "db:generate": "LOCAL_DB_PATH=$(find .wrangler/state/v3/d1/miniflare-D1DatabaseObject -type f -name '*.sqlite' -print -quit) drizzle-kit generate", + "db:generate": "drizzle-kit generate", "db:migrate": "wrangler d1 migrations apply goosify --local", "db:studio": "drizzle-kit studio", "db:touch": "wrangler d1 execute goosify --local --command='SELECT 1'", diff --git a/sample-apps/goosify/src/db/schema.ts b/sample-apps/goosify/src/db/schema.ts index fae7b6858..a757e9d61 100644 --- a/sample-apps/goosify/src/db/schema.ts +++ b/sample-apps/goosify/src/db/schema.ts @@ -1,5 +1,5 @@ import { sql } from "drizzle-orm"; -import { text, integer, sqliteTable } from "drizzle-orm/sqlite-core"; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const geese = sqliteTable("geese", { id: integer("id", { mode: "number" }).primaryKey(), diff --git a/sample-apps/goosify/src/index.ts b/sample-apps/goosify/src/index.ts index f2966d529..a92ad2149 100644 --- a/sample-apps/goosify/src/index.ts +++ b/sample-apps/goosify/src/index.ts @@ -1,4 +1,6 @@ +import { drizzle } from "drizzle-orm/d1"; import { Hono } from "hono"; +import * as schema from "./db/schema"; type Bindings = { DB: D1Database; @@ -14,9 +16,11 @@ app.get("/", (c) => { }); app.get("/api/geese", async (c) => { - const geese = await c.env.DB.prepare("SELECT * FROM geese").all(); + // NOTE - This is equivalent to a raw D1 query + // const geese = await c.env.DB.prepare("SELECT * FROM geese").all(); + const db = drizzle(c.env.DB); + const geese = await db.select().from(schema.geese); return c.json({ geese }); }); - export default app; From 20fe1943445b48de6a0d3553ba0c4ffeb04e5225 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 27 Aug 2024 14:14:52 +0200 Subject: [PATCH 07/25] Try implementing a d1 binding --- .../client-library-otel/src/cf-bindings.ts | 18 +++++++++++++++--- sample-apps/goosify/.dev.vars.example | 2 +- sample-apps/goosify/src/index.ts | 4 +++- sample-apps/goosify/wrangler.toml | 3 +++ 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/client-library-otel/src/cf-bindings.ts b/packages/client-library-otel/src/cf-bindings.ts index 04d0a422d..e61f8f251 100644 --- a/packages/client-library-otel/src/cf-bindings.ts +++ b/packages/client-library-otel/src/cf-bindings.ts @@ -3,7 +3,7 @@ import { measure } from "./measure"; // TODO - Can we use a Symbol here instead? const IS_PROXIED_KEY = "__fpx_proxied"; -export function isCloudflareAiBinding(o: unknown) { +function isCloudflareAiBinding(o: unknown) { const constructorName = getConstructorName(o); if (constructorName !== "Ai") { return false; @@ -13,7 +13,7 @@ export function isCloudflareAiBinding(o: unknown) { return true; } -export function isCloudflareR2Binding(o: unknown) { +function isCloudflareR2Binding(o: unknown) { const constructorName = getConstructorName(o); if (constructorName !== "R2Bucket") { return false; @@ -23,9 +23,19 @@ export function isCloudflareR2Binding(o: unknown) { return true; } +export function isCloudflareD1Binding(o: unknown) { + const constructorName = getConstructorName(o); + if (constructorName !== "D1Database") { + return false; + } + + console.log("D1 shall be proxied", o); + return true; +} + // TODO - Remove this, it is temporary function isCloudflareBinding(o: unknown): o is object { - return isCloudflareAiBinding(o) || isCloudflareR2Binding(o); + return isCloudflareAiBinding(o) || isCloudflareR2Binding(o) || isCloudflareD1Binding(o); } /** @@ -60,6 +70,8 @@ export function proxyCloudflareBinding(o: unknown, bindingName: string) { return o; } + console.log("Proxying binding", bindingName, o); + const proxiedBinding = new Proxy(o, { get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver); diff --git a/sample-apps/goosify/.dev.vars.example b/sample-apps/goosify/.dev.vars.example index f87f5c14c..168ca871f 100644 --- a/sample-apps/goosify/.dev.vars.example +++ b/sample-apps/goosify/.dev.vars.example @@ -1 +1 @@ -# TODO \ No newline at end of file +FPX_ENDPOINT=... \ No newline at end of file diff --git a/sample-apps/goosify/src/index.ts b/sample-apps/goosify/src/index.ts index a92ad2149..0ce5d8ac5 100644 --- a/sample-apps/goosify/src/index.ts +++ b/sample-apps/goosify/src/index.ts @@ -1,5 +1,6 @@ import { drizzle } from "drizzle-orm/d1"; import { Hono } from "hono"; +import { instrument } from "@fiberplane/hono-otel"; import * as schema from "./db/schema"; type Bindings = { @@ -7,6 +8,7 @@ type Bindings = { AI: Ai; GOOSIFY_KV: KVNamespace; GOOSIFY_R2: R2Bucket; + FPX_ENDPOINT: string; }; const app = new Hono<{ Bindings: Bindings }>(); @@ -23,4 +25,4 @@ app.get("/api/geese", async (c) => { return c.json({ geese }); }); -export default app; +export default instrument(app); diff --git a/sample-apps/goosify/wrangler.toml b/sample-apps/goosify/wrangler.toml index 23f5b4f4d..0637636c1 100644 --- a/sample-apps/goosify/wrangler.toml +++ b/sample-apps/goosify/wrangler.toml @@ -2,6 +2,9 @@ name = "goosify" compatibility_date = "2024-08-27" compatibility_flags = [ "nodejs_compat" ] +# [dev] +# port = 3003 + [[kv_namespaces]] binding = "GOOSIFY_KV" id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" From 510908b3d77f19b9ad24aae9794430ee8f1ac76b Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 27 Aug 2024 14:19:01 +0200 Subject: [PATCH 08/25] Remove cl --- packages/client-library-otel/src/cf-bindings.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/client-library-otel/src/cf-bindings.ts b/packages/client-library-otel/src/cf-bindings.ts index e61f8f251..b76a4ef02 100644 --- a/packages/client-library-otel/src/cf-bindings.ts +++ b/packages/client-library-otel/src/cf-bindings.ts @@ -29,7 +29,6 @@ export function isCloudflareD1Binding(o: unknown) { return false; } - console.log("D1 shall be proxied", o); return true; } @@ -70,8 +69,6 @@ export function proxyCloudflareBinding(o: unknown, bindingName: string) { return o; } - console.log("Proxying binding", bindingName, o); - const proxiedBinding = new Proxy(o, { get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver); From 3643ff7bd731e7db6685519997929cbf9bd39084 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 27 Aug 2024 14:19:19 +0200 Subject: [PATCH 09/25] Format --- packages/client-library-otel/src/cf-bindings.ts | 6 +++++- sample-apps/goosify/src/index.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/client-library-otel/src/cf-bindings.ts b/packages/client-library-otel/src/cf-bindings.ts index b76a4ef02..5275c3310 100644 --- a/packages/client-library-otel/src/cf-bindings.ts +++ b/packages/client-library-otel/src/cf-bindings.ts @@ -34,7 +34,11 @@ export function isCloudflareD1Binding(o: unknown) { // TODO - Remove this, it is temporary function isCloudflareBinding(o: unknown): o is object { - return isCloudflareAiBinding(o) || isCloudflareR2Binding(o) || isCloudflareD1Binding(o); + return ( + isCloudflareAiBinding(o) || + isCloudflareR2Binding(o) || + isCloudflareD1Binding(o) + ); } /** diff --git a/sample-apps/goosify/src/index.ts b/sample-apps/goosify/src/index.ts index 0ce5d8ac5..70b03f591 100644 --- a/sample-apps/goosify/src/index.ts +++ b/sample-apps/goosify/src/index.ts @@ -1,6 +1,6 @@ +import { instrument } from "@fiberplane/hono-otel"; import { drizzle } from "drizzle-orm/d1"; import { Hono } from "hono"; -import { instrument } from "@fiberplane/hono-otel"; import * as schema from "./db/schema"; type Bindings = { From cde2d441e0952b530fb403f1e774723dbc778931 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 27 Aug 2024 15:34:30 +0200 Subject: [PATCH 10/25] Rename some attrs --- packages/client-library-otel/src/cf-bindings.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/client-library-otel/src/cf-bindings.ts b/packages/client-library-otel/src/cf-bindings.ts index 5275c3310..c6363dded 100644 --- a/packages/client-library-otel/src/cf-bindings.ts +++ b/packages/client-library-otel/src/cf-bindings.ts @@ -86,9 +86,9 @@ export function proxyCloudflareBinding(o: unknown, bindingName: string) { { name, attributes: { - "cloudflare.binding.method": methodName, - "cloudflare.binding.name": bindingName, - "cloudflare.binding.type": bindingType, + "cf.binding.method": methodName, + "cf.binding.name": bindingName, + "cf.binding.type": bindingType, }, // TODO - Use these three callbacks to add additional attributes to the span // From 5cf907ffa5f5d63f7d49a24d5a7f85fff1092d1b Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 27 Aug 2024 16:55:09 +0200 Subject: [PATCH 11/25] Start adding more attributes depending on the args to the binding, etc --- .../client-library-otel/src/cf-bindings.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/client-library-otel/src/cf-bindings.ts b/packages/client-library-otel/src/cf-bindings.ts index c6363dded..5bd7c182b 100644 --- a/packages/client-library-otel/src/cf-bindings.ts +++ b/packages/client-library-otel/src/cf-bindings.ts @@ -1,4 +1,5 @@ import { measure } from "./measure"; +import { errorToJson, safelySerializeJSON } from "./utils"; // TODO - Can we use a Symbol here instead? const IS_PROXIED_KEY = "__fpx_proxied"; @@ -81,7 +82,8 @@ export function proxyCloudflareBinding(o: unknown, bindingName: string) { const methodName = String(prop); // OPTIMIZE - Do we want to do these lookups / this wrapping every time the property is accessed? const bindingType = getConstructorName(target); - const name = `${bindingType}.${bindingName}.${methodName}`; + // Use the user's binding name, not the Cloudflare constructor name + const name = `${bindingName}.${methodName}`; const measuredBinding = measure( { name, @@ -90,11 +92,24 @@ export function proxyCloudflareBinding(o: unknown, bindingName: string) { "cf.binding.name": bindingName, "cf.binding.type": bindingType, }, - // TODO - Use these three callbacks to add additional attributes to the span + onStart: (span, args) => { + span.setAttributes({ + args: safelySerializeJSON(args), + }); + }, + // TODO - Use this callback to add additional attributes to the span regarding the response... + // But the thing is, the result could be so wildly different depending on the method! + // Might be good to proxy each binding individually, eventually? // - // onStart: (span, args) => {}, // onSuccess: (span, result) => {}, - // onError: (span, error) => {}, + onError: (span, error) => { + const serializableError = + error instanceof Error ? errorToJson(error) : error; + const errorAttributes = { + "cf.binding.error": safelySerializeJSON(serializableError), + }; + span.setAttributes(errorAttributes); + }, }, // OPTIMIZE - bind is expensive, can we avoid it? value.bind(target), From 3b17a752a1a2faaa870d11075fbdd9c6e72a0fd9 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 27 Aug 2024 18:03:58 +0200 Subject: [PATCH 12/25] Proxy kv --- .../client-library-otel/src/cf-bindings.ts | 86 +++++++++++-------- 1 file changed, 50 insertions(+), 36 deletions(-) diff --git a/packages/client-library-otel/src/cf-bindings.ts b/packages/client-library-otel/src/cf-bindings.ts index 5bd7c182b..c016cfcbb 100644 --- a/packages/client-library-otel/src/cf-bindings.ts +++ b/packages/client-library-otel/src/cf-bindings.ts @@ -4,43 +4,7 @@ import { errorToJson, safelySerializeJSON } from "./utils"; // TODO - Can we use a Symbol here instead? const IS_PROXIED_KEY = "__fpx_proxied"; -function isCloudflareAiBinding(o: unknown) { - const constructorName = getConstructorName(o); - if (constructorName !== "Ai") { - return false; - } - - // TODO - Edge case, also check for `fetcher` and other known properties on this binding, in case the user is using another class named Ai (?) - return true; -} - -function isCloudflareR2Binding(o: unknown) { - const constructorName = getConstructorName(o); - if (constructorName !== "R2Bucket") { - return false; - } - - // TODO - Edge case, also check for `list`, `delete`, and other known methods on this binding, in case the user is using another class named R2Bucket (?) - return true; -} - -export function isCloudflareD1Binding(o: unknown) { - const constructorName = getConstructorName(o); - if (constructorName !== "D1Database") { - return false; - } - - return true; -} -// TODO - Remove this, it is temporary -function isCloudflareBinding(o: unknown): o is object { - return ( - isCloudflareAiBinding(o) || - isCloudflareR2Binding(o) || - isCloudflareD1Binding(o) - ); -} /** * Proxy a Cloudflare binding to add instrumentation. @@ -128,6 +92,54 @@ export function proxyCloudflareBinding(o: unknown, bindingName: string) { return proxiedBinding; } +function isCloudflareAiBinding(o: unknown) { + const constructorName = getConstructorName(o); + if (constructorName !== "Ai") { + return false; + } + + // TODO - Edge case, also check for `fetcher` and other known properties on this binding, in case the user is using another class named Ai (?) + return true; +} + +function isCloudflareR2Binding(o: unknown) { + const constructorName = getConstructorName(o); + if (constructorName !== "R2Bucket") { + return false; + } + + // TODO - Edge case, also check for `list`, `delete`, and other known methods on this binding, in case the user is using another class named R2Bucket (?) + return true; +} + +function isCloudflareD1Binding(o: unknown) { + const constructorName = getConstructorName(o); + if (constructorName !== "D1Database") { + return false; + } + + return true; +} + +function isCloudflareKVBinding(o: unknown) { + const constructorName = getConstructorName(o); + if (constructorName !== "KVNamespace") { + return false; + } + + return true; +} + +// TODO - Remove this, it is temporary +function isCloudflareBinding(o: unknown): o is object { + return ( + isCloudflareAiBinding(o) || + isCloudflareR2Binding(o) || + isCloudflareD1Binding(o) || + isCloudflareKVBinding(o) + ); +} + /** * Get the constructor name of an object * Helps us detect Cloudflare bindings @@ -171,3 +183,5 @@ function markAsProxied(o: object) { configurable: true, }); } + + From 0f318f6dc0e94764b4cb8bab82383a9b900ef02d Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 27 Aug 2024 19:30:04 +0200 Subject: [PATCH 13/25] Finish D1 instrumentation --- .../client-library-otel/src/cf-bindings.ts | 78 ++++++++++++++- .../migrations/0001_omniscient_silver_fox.sql | 6 ++ .../migrations/meta/0001_snapshot.json | 98 +++++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 ++ sample-apps/goosify/src/db/schema.ts | 7 ++ sample-apps/goosify/src/index.ts | 55 +++++++++++ 6 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 sample-apps/goosify/drizzle/migrations/0001_omniscient_silver_fox.sql create mode 100644 sample-apps/goosify/drizzle/migrations/meta/0001_snapshot.json diff --git a/packages/client-library-otel/src/cf-bindings.ts b/packages/client-library-otel/src/cf-bindings.ts index c016cfcbb..2ad2f4e22 100644 --- a/packages/client-library-otel/src/cf-bindings.ts +++ b/packages/client-library-otel/src/cf-bindings.ts @@ -4,8 +4,6 @@ import { errorToJson, safelySerializeJSON } from "./utils"; // TODO - Can we use a Symbol here instead? const IS_PROXIED_KEY = "__fpx_proxied"; - - /** * Proxy a Cloudflare binding to add instrumentation. * For now, just wraps all functions on the binding to use a measured version of the function. @@ -38,14 +36,21 @@ export function proxyCloudflareBinding(o: unknown, bindingName: string) { return o; } + // HACK - Clean this up. Special logic for D1. + if (isCloudflareD1Binding(o)) { + return proxyD1Binding(o, bindingName); + } + const proxiedBinding = new Proxy(o, { get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver); if (typeof value === "function") { const methodName = String(prop); + // OPTIMIZE - Do we want to do these lookups / this wrapping every time the property is accessed? const bindingType = getConstructorName(target); + // Use the user's binding name, not the Cloudflare constructor name const name = `${bindingName}.${methodName}`; const measuredBinding = measure( @@ -123,7 +128,7 @@ function isCloudflareD1Binding(o: unknown) { function isCloudflareKVBinding(o: unknown) { const constructorName = getConstructorName(o); - if (constructorName !== "KVNamespace") { + if (constructorName !== "KvNamespace") { return false; } @@ -184,4 +189,71 @@ function markAsProxied(o: object) { }); } +/** + * Proxy a D1 binding to add instrumentation. + * + * What this actually does is create a proxy of the `database` prop... I hope this works + * + * @param o - The D1Database binding to proxy + * + * @returns A proxied binding, whose `.database._send` and `.database._sendOrThrow` methods are instrumented + */ +function proxyD1Binding(o: unknown, bindingName: string) { + if (!o || typeof o !== "object") { + return o; + } + + if (!isCloudflareD1Binding(o)) { + return o; + } + + if (isAlreadyProxied(o)) { + return o; + } + + const d1Proxy = new Proxy(o, { + get(d1Target, d1Prop) { + const d1Method = String(d1Prop); + const d1Value = Reflect.get(d1Target, d1Prop); + // HACK - These are technically public methods on the database object, + // but they have an underscore prefix which usually means "private" by convention... + // BEWARE!!! + const isSendingMethod = d1Method === "_send" || d1Method === "_sendOrThrow"; + if (typeof d1Value === "function" && isSendingMethod) { + // ... + return measure({ + name: "D1 Call", + attributes: { + "cf.binding.method": d1Method, + "cf.binding.name": bindingName, + "cf.binding.type": "D1Database", + }, + onStart: (span, args) => { + span.setAttributes({ + args: safelySerializeJSON(args), + }); + }, + // TODO - Use this callback to add additional attributes to the span regarding the response... + // But the thing is, the result could be so wildly different depending on the method! + // Might be good to proxy each binding individually, eventually? + // + // onSuccess: (span, result) => {}, + onError: (span, error) => { + const serializableError = + error instanceof Error ? errorToJson(error) : error; + const errorAttributes = { + "cf.binding.error": safelySerializeJSON(serializableError), + }; + span.setAttributes(errorAttributes); + }, + }, d1Value.bind(d1Target)); + } + + return d1Value; + }, + }); + + markAsProxied(d1Proxy); + return d1Proxy; +} \ No newline at end of file diff --git a/sample-apps/goosify/drizzle/migrations/0001_omniscient_silver_fox.sql b/sample-apps/goosify/drizzle/migrations/0001_omniscient_silver_fox.sql new file mode 100644 index 000000000..9c1b51716 --- /dev/null +++ b/sample-apps/goosify/drizzle/migrations/0001_omniscient_silver_fox.sql @@ -0,0 +1,6 @@ +CREATE TABLE `goose_images` ( + `id` integer PRIMARY KEY NOT NULL, + `filename` text NOT NULL, + `prompt` text NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL +); diff --git a/sample-apps/goosify/drizzle/migrations/meta/0001_snapshot.json b/sample-apps/goosify/drizzle/migrations/meta/0001_snapshot.json new file mode 100644 index 000000000..3294edb71 --- /dev/null +++ b/sample-apps/goosify/drizzle/migrations/meta/0001_snapshot.json @@ -0,0 +1,98 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9beaed9b-6535-4fd9-a201-6872fc1cf72d", + "prevId": "7a67a8d6-2ac2-4242-948d-048e03cba163", + "tables": { + "geese": { + "name": "geese", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "goose_images": { + "name": "goose_images", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/sample-apps/goosify/drizzle/migrations/meta/_journal.json b/sample-apps/goosify/drizzle/migrations/meta/_journal.json index 9dea3f86e..f7f961595 100644 --- a/sample-apps/goosify/drizzle/migrations/meta/_journal.json +++ b/sample-apps/goosify/drizzle/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1724758784931, "tag": "0000_new_human_robot", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1724775919239, + "tag": "0001_omniscient_silver_fox", + "breakpoints": true } ] } \ No newline at end of file diff --git a/sample-apps/goosify/src/db/schema.ts b/sample-apps/goosify/src/db/schema.ts index a757e9d61..9f2d8ef9e 100644 --- a/sample-apps/goosify/src/db/schema.ts +++ b/sample-apps/goosify/src/db/schema.ts @@ -8,3 +8,10 @@ export const geese = sqliteTable("geese", { createdAt: text("created_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), updatedAt: text("updated_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), }); + +export const gooseImages = sqliteTable("goose_images", { + id: integer("id", { mode: "number" }).primaryKey(), + filename: text("filename").notNull(), + prompt: text("prompt").notNull(), + createdAt: text("created_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), +}); diff --git a/sample-apps/goosify/src/index.ts b/sample-apps/goosify/src/index.ts index 70b03f591..fef3fc213 100644 --- a/sample-apps/goosify/src/index.ts +++ b/sample-apps/goosify/src/index.ts @@ -13,6 +13,26 @@ type Bindings = { const app = new Hono<{ Bindings: Bindings }>(); +const LATEST_LOCALE_KEY = "latest_locale"; + +// Middleware to set the locale in kv-storage +app.use(async (c, next) => { + console.log("Setting locale"); + const storedLocale = await c.env.GOOSIFY_KV.get(LATEST_LOCALE_KEY); + + let locale = c.req.header("Accept-Language") || "en"; + + // Optional: Parse the "Accept-Language" header to get the most preferred language + locale = parseAcceptLanguage(locale); + + if (storedLocale !== locale) { + console.log(`Setting latest locale to ${locale}`); + await c.env.GOOSIFY_KV.put(LATEST_LOCALE_KEY, locale); + } + + await next(); +}); + app.get("/", (c) => { return c.text("Hello Hono!"); }); @@ -25,4 +45,39 @@ app.get("/api/geese", async (c) => { return c.json({ geese }); }); +// TODO +app.get("/api/cyberpunk-goose", async (c) => { + const inputs = { + prompt: "cyberpunk goose", + }; + const cyberpunkGooseImage = await c.env.AI.run( + "@cf/lykon/dreamshaper-8-lcm", + inputs + ); + + const blob = new Blob([cyberpunkGooseImage], { type: 'image/png' }); + const filename = `cyberpunk-goose--${crypto.randomUUID()}.png`; + await c.env.GOOSIFY_R2.put(filename, blob); + + const db = drizzle(c.env.DB); + await db.insert(schema.gooseImages).values({ + filename, + prompt: inputs.prompt, + }); + + c.header("Content-Type", "image/png"); + return c.body(cyberpunkGooseImage); +}); + export default instrument(app); + + +function parseAcceptLanguage(acceptLanguage: string) { + // Simple parser to get the most preferred language + const locales = acceptLanguage.split(',').map(lang => { + const parts = lang.split(';'); + return { locale: parts[0], q: parts[1] ? Number.parseFloat(parts[1].split('=')[1]) : 1 }; + }); + locales.sort((a, b) => b.q - a.q); + return locales[0].locale; // Return the most preferred language +} \ No newline at end of file From 27fdd02929a98340bffbebf65c17a15f0102d903 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 27 Aug 2024 19:37:14 +0200 Subject: [PATCH 14/25] Format --- .../client-library-otel/src/cf-bindings.ts | 60 ++++++++++--------- sample-apps/goosify/src/index.ts | 16 ++--- 2 files changed, 41 insertions(+), 35 deletions(-) diff --git a/packages/client-library-otel/src/cf-bindings.ts b/packages/client-library-otel/src/cf-bindings.ts index 2ad2f4e22..7af05f872 100644 --- a/packages/client-library-otel/src/cf-bindings.ts +++ b/packages/client-library-otel/src/cf-bindings.ts @@ -191,7 +191,7 @@ function markAsProxied(o: object) { /** * Proxy a D1 binding to add instrumentation. - * + * * What this actually does is create a proxy of the `database` prop... I hope this works * * @param o - The D1Database binding to proxy @@ -218,35 +218,39 @@ function proxyD1Binding(o: unknown, bindingName: string) { // HACK - These are technically public methods on the database object, // but they have an underscore prefix which usually means "private" by convention... // BEWARE!!! - const isSendingMethod = d1Method === "_send" || d1Method === "_sendOrThrow"; + const isSendingMethod = + d1Method === "_send" || d1Method === "_sendOrThrow"; if (typeof d1Value === "function" && isSendingMethod) { // ... - return measure({ - name: "D1 Call", - attributes: { - "cf.binding.method": d1Method, - "cf.binding.name": bindingName, - "cf.binding.type": "D1Database", - }, - onStart: (span, args) => { - span.setAttributes({ - args: safelySerializeJSON(args), - }); - }, - // TODO - Use this callback to add additional attributes to the span regarding the response... - // But the thing is, the result could be so wildly different depending on the method! - // Might be good to proxy each binding individually, eventually? - // - // onSuccess: (span, result) => {}, - onError: (span, error) => { - const serializableError = - error instanceof Error ? errorToJson(error) : error; - const errorAttributes = { - "cf.binding.error": safelySerializeJSON(serializableError), - }; - span.setAttributes(errorAttributes); + return measure( + { + name: "D1 Call", + attributes: { + "cf.binding.method": d1Method, + "cf.binding.name": bindingName, + "cf.binding.type": "D1Database", + }, + onStart: (span, args) => { + span.setAttributes({ + args: safelySerializeJSON(args), + }); + }, + // TODO - Use this callback to add additional attributes to the span regarding the response... + // But the thing is, the result could be so wildly different depending on the method! + // Might be good to proxy each binding individually, eventually? + // + // onSuccess: (span, result) => {}, + onError: (span, error) => { + const serializableError = + error instanceof Error ? errorToJson(error) : error; + const errorAttributes = { + "cf.binding.error": safelySerializeJSON(serializableError), + }; + span.setAttributes(errorAttributes); + }, }, - }, d1Value.bind(d1Target)); + d1Value.bind(d1Target), + ); } return d1Value; @@ -256,4 +260,4 @@ function proxyD1Binding(o: unknown, bindingName: string) { markAsProxied(d1Proxy); return d1Proxy; -} \ No newline at end of file +} diff --git a/sample-apps/goosify/src/index.ts b/sample-apps/goosify/src/index.ts index fef3fc213..8b80fa899 100644 --- a/sample-apps/goosify/src/index.ts +++ b/sample-apps/goosify/src/index.ts @@ -52,10 +52,10 @@ app.get("/api/cyberpunk-goose", async (c) => { }; const cyberpunkGooseImage = await c.env.AI.run( "@cf/lykon/dreamshaper-8-lcm", - inputs + inputs, ); - const blob = new Blob([cyberpunkGooseImage], { type: 'image/png' }); + const blob = new Blob([cyberpunkGooseImage], { type: "image/png" }); const filename = `cyberpunk-goose--${crypto.randomUUID()}.png`; await c.env.GOOSIFY_R2.put(filename, blob); @@ -71,13 +71,15 @@ app.get("/api/cyberpunk-goose", async (c) => { export default instrument(app); - function parseAcceptLanguage(acceptLanguage: string) { // Simple parser to get the most preferred language - const locales = acceptLanguage.split(',').map(lang => { - const parts = lang.split(';'); - return { locale: parts[0], q: parts[1] ? Number.parseFloat(parts[1].split('=')[1]) : 1 }; + const locales = acceptLanguage.split(",").map((lang) => { + const parts = lang.split(";"); + return { + locale: parts[0], + q: parts[1] ? Number.parseFloat(parts[1].split("=")[1]) : 1, + }; }); locales.sort((a, b) => b.q - a.q); return locales[0].locale; // Return the most preferred language -} \ No newline at end of file +} From 9d2091f7b355a45025035191c088119a6026fa87 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 28 Aug 2024 07:49:41 +0200 Subject: [PATCH 15/25] Clean up cf binding code and move to patch module --- .../src/instrumentation.ts | 38 +-- .../src/{ => patch}/cf-bindings.ts | 221 ++++++++++-------- .../client-library-otel/src/patch/index.ts | 1 + 3 files changed, 140 insertions(+), 120 deletions(-) rename packages/client-library-otel/src/{ => patch}/cf-bindings.ts (74%) diff --git a/packages/client-library-otel/src/instrumentation.ts b/packages/client-library-otel/src/instrumentation.ts index 03924bf2e..e4afb2e4f 100644 --- a/packages/client-library-otel/src/instrumentation.ts +++ b/packages/client-library-otel/src/instrumentation.ts @@ -9,9 +9,13 @@ 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 { proxyCloudflareBinding } from "./cf-bindings"; import { measure } from "./measure"; -import { patchConsole, patchFetch, patchWaitUntil } from "./patch"; +import { + patchCloudflareBindings, + patchConsole, + patchFetch, + patchWaitUntil, +} from "./patch"; import { propagateFpxTraceId } from "./propagation"; import { isRouteInspectorRequest, respondWithRoutes } from "./routes"; import type { HonoLikeApp, HonoLikeEnv, HonoLikeFetch } from "./types"; @@ -58,9 +62,15 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) { const originalFetch = value as HonoLikeFetch; return async function fetch( request: Request, - env: HonoLikeEnv, + // Name this "rawEnv" because we coerce it for our sanity below + rawEnv: HonoLikeEnv, executionContext: ExecutionContext | undefined, ) { + const env = rawEnv as + | undefined + | null + | Record; + // 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. @@ -71,7 +81,7 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) { const isEnabled = !!endpoint && typeof endpoint === "string"; if (!isEnabled) { - return await originalFetch(request, env, executionContext); + return await originalFetch(request, rawEnv, executionContext); } // If the request is from the route inspector, respond with the routes @@ -79,9 +89,7 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) { return respondWithRoutes(webStandardFetch, endpoint, app); } - const serviceName = - (env as Record).FPX_SERVICE_NAME ?? - "unknown"; + const serviceName = env?.FPX_SERVICE_NAME ?? "unknown"; // Patch the related functions to monitor const { @@ -92,15 +100,7 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) { }, } = mergeConfigs(defaultConfig, config); if (monitorCfBindings) { - const envKeys = env ? Object.keys(env) : []; - for (const bindingName of envKeys) { - // @ts-expect-error - We know that env is a Record - env[bindingName] = proxyCloudflareBinding( - // @ts-expect-error - We know that env is a Record - env[bindingName], - bindingName, - ); - } + patchCloudflareBindings(env); } if (monitorLogging) { patchConsole(); @@ -190,7 +190,11 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) { try { return await context.with(activeContext, async () => { - return await measuredFetch(newRequest, env, proxyExecutionCtx); + return await measuredFetch( + newRequest, + env as HonoLikeEnv, + proxyExecutionCtx, + ); }); } finally { // Make sure all promises are resolved before sending data to the server diff --git a/packages/client-library-otel/src/cf-bindings.ts b/packages/client-library-otel/src/patch/cf-bindings.ts similarity index 74% rename from packages/client-library-otel/src/cf-bindings.ts rename to packages/client-library-otel/src/patch/cf-bindings.ts index 7af05f872..6c403bb74 100644 --- a/packages/client-library-otel/src/cf-bindings.ts +++ b/packages/client-library-otel/src/patch/cf-bindings.ts @@ -1,28 +1,42 @@ -import { measure } from "./measure"; -import { errorToJson, safelySerializeJSON } from "./utils"; +import type { Span } from "@opentelemetry/api"; +import { measure } from "../measure"; +import { errorToJson, safelySerializeJSON } from "../utils"; -// TODO - Can we use a Symbol here instead? +/** + * A key used to mark objects as proxied by us, so that we don't proxy them again. + * + * @internal + */ const IS_PROXIED_KEY = "__fpx_proxied"; +export function patchCloudflareBindings( + env?: Record | null, +) { + const envKeys = env ? Object.keys(env) : []; + for (const bindingName of envKeys) { + // @ts-expect-error - We know that env is a Record + env[bindingName] = patchCloudflareBinding( + // @ts-expect-error - We know that env is a Record + env[bindingName], + bindingName, + ); + } +} + /** * Proxy a Cloudflare binding to add instrumentation. * For now, just wraps all functions on the binding to use a measured version of the function. * - * For R2, we could specifically proxy and add smarts for: - * - get - * - list - * - head - * - put - * - delete + * For R2, we could still specifically proxy and add smarts for: * - createMultipartUpload * - resumeMultipartUpload * * @param o - The binding to proxy - * @param bindingName - The name of the binding in the environment, e.g., "Ai" or "AVATARS_BUCKET" + * @param bindingName - The name of the binding in the environment, e.g., "AI" or "AVATARS_BUCKET" * * @returns A proxied binding */ -export function proxyCloudflareBinding(o: unknown, bindingName: string) { +function patchCloudflareBinding(o: unknown, bindingName: string) { // HACK - This makes typescript happier about proxying the object if (!o || typeof o !== "object") { return o; @@ -36,7 +50,8 @@ export function proxyCloudflareBinding(o: unknown, bindingName: string) { return o; } - // HACK - Clean this up. Special logic for D1. + // HACK - Special logic for D1, since we only really care about the `_send` and `_sendOrThrow` methods, + // not about the `prepare`, etc, methods. if (isCloudflareD1Binding(o)) { return proxyD1Binding(o, bindingName); } @@ -71,14 +86,7 @@ export function proxyCloudflareBinding(o: unknown, bindingName: string) { // Might be good to proxy each binding individually, eventually? // // onSuccess: (span, result) => {}, - onError: (span, error) => { - const serializableError = - error instanceof Error ? errorToJson(error) : error; - const errorAttributes = { - "cf.binding.error": safelySerializeJSON(serializableError), - }; - span.setAttributes(errorAttributes); - }, + onError: handleError, }, // OPTIMIZE - bind is expensive, can we avoid it? value.bind(target), @@ -97,6 +105,95 @@ export function proxyCloudflareBinding(o: unknown, bindingName: string) { return proxiedBinding; } +/** + * Proxy a D1 binding to add instrumentation to database calls. + * + * In order to instrument the calls to the database itself, we need to proxy the `_send` and `_sendOrThrow` methods. + * As of writing, the code that makes these calls is here: + * https://github.com/cloudflare/workerd/blob/bee639d6c2ff41bfc1bd75a40c9d3c98724585ce/src/cloudflare/internal/d1-api.ts#L131 + * + * @param o - The D1Database binding to proxy + * + * @returns A proxied binding, whose `.database._send` and `.database._sendOrThrow` methods are instrumented + */ +function proxyD1Binding(o: unknown, bindingName: string) { + if (!o || typeof o !== "object") { + return o; + } + + if (!isCloudflareD1Binding(o)) { + return o; + } + + if (isAlreadyProxied(o)) { + return o; + } + + const d1Proxy = new Proxy(o, { + get(d1Target, d1Prop) { + const d1Method = String(d1Prop); + const d1Value = Reflect.get(d1Target, d1Prop); + // HACK - These are technically public methods on the database object, + // but they have an underscore prefix which usually means "private" by convention. + // + const isSendingQuery = + d1Method === "_send" || d1Method === "_sendOrThrow"; + if (typeof d1Value === "function" && isSendingQuery) { + return measure( + { + name: "D1 Call", + attributes: { + "cf.binding.method": d1Method, + "cf.binding.name": bindingName, + "cf.binding.type": "D1Database", + }, + onStart: (span, args) => { + span.setAttributes({ + args: safelySerializeJSON(args), + }); + }, + // TODO - Use this callback to add additional attributes to the span regarding the response. + // onSuccess: (span, result) => {}, + onError: handleError, + }, + // OPTIMIZE - bind is expensive, can we avoid it? + d1Value.bind(d1Target), + ); + } + + return d1Value; + }, + }); + + markAsProxied(d1Proxy); + + return d1Proxy; +} + +/** + * Add "cf.binding.error" attribute to a span + * + * @param span - The span to add the attribute to + * @param error - The error to add to the span + */ +function handleError(span: Span, error: unknown) { + const serializableError = error instanceof Error ? errorToJson(error) : error; + const errorAttributes = { + "cf.binding.error": safelySerializeJSON(serializableError), + }; + span.setAttributes(errorAttributes); +} + +// TODO - Remove this, it is temporary +function isCloudflareBinding(o: unknown): o is object { + return ( + isCloudflareAiBinding(o) || + isCloudflareR2Binding(o) || + isCloudflareD1Binding(o) || + isCloudflareKVBinding(o) + ); +} + function isCloudflareAiBinding(o: unknown) { const constructorName = getConstructorName(o); if (constructorName !== "Ai") { @@ -135,18 +232,9 @@ function isCloudflareKVBinding(o: unknown) { return true; } -// TODO - Remove this, it is temporary -function isCloudflareBinding(o: unknown): o is object { - return ( - isCloudflareAiBinding(o) || - isCloudflareR2Binding(o) || - isCloudflareD1Binding(o) || - isCloudflareKVBinding(o) - ); -} - /** * Get the constructor name of an object + * * Helps us detect Cloudflare bindings * * @param o - The object to get the constructor name of @@ -188,76 +276,3 @@ function markAsProxied(o: object) { configurable: true, }); } - -/** - * Proxy a D1 binding to add instrumentation. - * - * What this actually does is create a proxy of the `database` prop... I hope this works - * - * @param o - The D1Database binding to proxy - * - * @returns A proxied binding, whose `.database._send` and `.database._sendOrThrow` methods are instrumented - */ -function proxyD1Binding(o: unknown, bindingName: string) { - if (!o || typeof o !== "object") { - return o; - } - - if (!isCloudflareD1Binding(o)) { - return o; - } - - if (isAlreadyProxied(o)) { - return o; - } - - const d1Proxy = new Proxy(o, { - get(d1Target, d1Prop) { - const d1Method = String(d1Prop); - const d1Value = Reflect.get(d1Target, d1Prop); - // HACK - These are technically public methods on the database object, - // but they have an underscore prefix which usually means "private" by convention... - // BEWARE!!! - const isSendingMethod = - d1Method === "_send" || d1Method === "_sendOrThrow"; - if (typeof d1Value === "function" && isSendingMethod) { - // ... - return measure( - { - name: "D1 Call", - attributes: { - "cf.binding.method": d1Method, - "cf.binding.name": bindingName, - "cf.binding.type": "D1Database", - }, - onStart: (span, args) => { - span.setAttributes({ - args: safelySerializeJSON(args), - }); - }, - // TODO - Use this callback to add additional attributes to the span regarding the response... - // But the thing is, the result could be so wildly different depending on the method! - // Might be good to proxy each binding individually, eventually? - // - // onSuccess: (span, result) => {}, - onError: (span, error) => { - const serializableError = - error instanceof Error ? errorToJson(error) : error; - const errorAttributes = { - "cf.binding.error": safelySerializeJSON(serializableError), - }; - span.setAttributes(errorAttributes); - }, - }, - d1Value.bind(d1Target), - ); - } - - return d1Value; - }, - }); - - markAsProxied(d1Proxy); - - return d1Proxy; -} diff --git a/packages/client-library-otel/src/patch/index.ts b/packages/client-library-otel/src/patch/index.ts index c976ec26e..3e5702bfc 100644 --- a/packages/client-library-otel/src/patch/index.ts +++ b/packages/client-library-otel/src/patch/index.ts @@ -1,3 +1,4 @@ export { patchFetch } from "./fetch"; export { patchConsole } from "./log"; export { patchWaitUntil } from "./waitUntil"; +export { patchCloudflareBindings } from "./cf-bindings"; From 4ab2e86e9adfa64b87212579cb7a85f617dee9bd Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 28 Aug 2024 08:08:46 +0200 Subject: [PATCH 16/25] Use constants for cf binding attributes --- packages/client-library-otel/src/constants.ts | 5 ++ .../src/instrumentation.ts | 24 ++++-- .../src/patch/cf-bindings.ts | 85 ++++++++++++------- 3 files changed, 79 insertions(+), 35 deletions(-) diff --git a/packages/client-library-otel/src/constants.ts b/packages/client-library-otel/src/constants.ts index 4d086dee2..cce6e98f7 100644 --- a/packages/client-library-otel/src/constants.ts +++ b/packages/client-library-otel/src/constants.ts @@ -15,6 +15,11 @@ export const FPX_REQUEST_ENV = "fpx.http.request.env"; export const FPX_RESPONSE_BODY = "fpx.http.response.body"; +export const CF_BINDING_TYPE = "cf.binding.type"; +export const CF_BINDING_NAME = "cf.binding.name"; +export const CF_BINDING_METHOD = "cf.binding.method"; +export const CF_BINDING_ERROR = "cf.binding.error"; + // NOT YET IMPLEMENTED export const FPX_REQUEST_HANDLER_FILE = "fpx.http.request.handler.file"; export const FPX_REQUEST_HANDLER_SOURCE_CODE = diff --git a/packages/client-library-otel/src/instrumentation.ts b/packages/client-library-otel/src/instrumentation.ts index e4afb2e4f..59cb2ee75 100644 --- a/packages/client-library-otel/src/instrumentation.ts +++ b/packages/client-library-otel/src/instrumentation.ts @@ -25,17 +25,29 @@ import { getRootRequestAttributes, } from "./utils"; +/** + * The type for the configuration object we use to configure the instrumentation + * Different from @FpxConfigOptions because all properties are required + * + * @internal + */ type FpxConfig = { monitor: { - /** Send data to FPX about each fetch call made during a handler's lifetime */ + /** Send data to FPX about each `fetch` call made during a handler's lifetime */ fetch: boolean; + /** Send data to FPX about each `console.*` call made during a handler's lifetime */ logging: boolean; /** Proxy Cloudflare bindings to add instrumentation */ cfBindings: boolean; }; }; -// TODO - Create helper type for making deeply partial types +/** + * The type for the configuration object the user might pass to `instrument` + * Different from @FpxConfig because all properties are optional + * + * @public + */ type FpxConfigOptions = Partial< FpxConfig & { monitor: Partial; @@ -43,6 +55,7 @@ type FpxConfigOptions = Partial< >; const defaultConfig = { + // TODO - Implement library debug logging // libraryDebugMode: false, monitor: { fetch: true, @@ -54,6 +67,7 @@ const defaultConfig = { export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) { // Freeze the web standard fetch function so that we can use it below to report registered routes back to fpx studio const webStandardFetch = fetch; + return new Proxy(app, { // Intercept the `fetch` function on the Hono app instance get(target, prop, receiver) { @@ -62,7 +76,7 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) { const originalFetch = value as HonoLikeFetch; return async function fetch( request: Request, - // Name this "rawEnv" because we coerce it for our sanity below + // Name this "rawEnv" because we coerce it below into something that's easier to work with rawEnv: HonoLikeEnv, executionContext: ExecutionContext | undefined, ) { @@ -75,9 +89,7 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) { // 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 as Record).FPX_ENDPOINT - : null; + typeof env === "object" && env !== null ? env.FPX_ENDPOINT : null; const isEnabled = !!endpoint && typeof endpoint === "string"; if (!isEnabled) { diff --git a/packages/client-library-otel/src/patch/cf-bindings.ts b/packages/client-library-otel/src/patch/cf-bindings.ts index 6c403bb74..6a6ff2925 100644 --- a/packages/client-library-otel/src/patch/cf-bindings.ts +++ b/packages/client-library-otel/src/patch/cf-bindings.ts @@ -1,4 +1,10 @@ import type { Span } from "@opentelemetry/api"; +import { + CF_BINDING_ERROR, + CF_BINDING_METHOD, + CF_BINDING_NAME, + CF_BINDING_TYPE, +} from "../constants"; import { measure } from "../measure"; import { errorToJson, safelySerializeJSON } from "../utils"; @@ -9,17 +15,25 @@ import { errorToJson, safelySerializeJSON } from "../utils"; */ const IS_PROXIED_KEY = "__fpx_proxied"; +/** + * Patch Cloudflare bindings to add instrumentation + * + * @param env - The environment for the worker, which may contain Cloudflare bindings + */ export function patchCloudflareBindings( - env?: Record | null, + env?: Record | null, ) { const envKeys = env ? Object.keys(env) : []; for (const bindingName of envKeys) { + // Skip any environment variables that are not objects, + // since they can't be bindings + const envValue = env?.[bindingName]; + if (!envValue || typeof envValue !== "object") { + continue; + } + // @ts-expect-error - We know that env is a Record - env[bindingName] = patchCloudflareBinding( - // @ts-expect-error - We know that env is a Record - env[bindingName], - bindingName, - ); + env[bindingName] = patchCloudflareBinding(envValue, bindingName); } } @@ -36,12 +50,7 @@ export function patchCloudflareBindings( * * @returns A proxied binding */ -function patchCloudflareBinding(o: unknown, bindingName: string) { - // HACK - This makes typescript happier about proxying the object - if (!o || typeof o !== "object") { - return o; - } - +function patchCloudflareBinding(o: object, bindingName: string) { if (!isCloudflareBinding(o)) { return o; } @@ -66,16 +75,17 @@ function patchCloudflareBinding(o: unknown, bindingName: string) { // OPTIMIZE - Do we want to do these lookups / this wrapping every time the property is accessed? const bindingType = getConstructorName(target); - // Use the user's binding name, not the Cloudflare constructor name + // The name for the span, which will show up in the UI const name = `${bindingName}.${methodName}`; + const measuredBinding = measure( { name, - attributes: { - "cf.binding.method": methodName, - "cf.binding.name": bindingName, - "cf.binding.type": bindingType, - }, + attributes: getCfBindingAttributes( + bindingType, + bindingName, + methodName, + ), onStart: (span, args) => { span.setAttributes({ args: safelySerializeJSON(args), @@ -116,11 +126,7 @@ function patchCloudflareBinding(o: unknown, bindingName: string) { * * @returns A proxied binding, whose `.database._send` and `.database._sendOrThrow` methods are instrumented */ -function proxyD1Binding(o: unknown, bindingName: string) { - if (!o || typeof o !== "object") { - return o; - } - +function proxyD1Binding(o: object, bindingName: string) { if (!isCloudflareD1Binding(o)) { return o; } @@ -142,11 +148,11 @@ function proxyD1Binding(o: unknown, bindingName: string) { return measure( { name: "D1 Call", - attributes: { - "cf.binding.method": d1Method, - "cf.binding.name": bindingName, - "cf.binding.type": "D1Database", - }, + attributes: getCfBindingAttributes( + "D1Database", + bindingName, + d1Method, + ), onStart: (span, args) => { span.setAttributes({ args: safelySerializeJSON(args), @@ -170,6 +176,27 @@ function proxyD1Binding(o: unknown, bindingName: string) { return d1Proxy; } +/** + * Get the attributes for a Cloudflare binding + * + * @param bindingType - The type of the binding, e.g., "D1Database" or "R2Bucket" + * @param bindingName - The name of the binding in the environment, e.g., "AI" or "AVATARS_BUCKET" + * @param methodName - The name of the method being called on the binding, e.g., "run" or "put" + * + * @returns The attributes for the binding + */ +function getCfBindingAttributes( + bindingType: string, + bindingName: string, + methodName: string, +) { + return { + [CF_BINDING_TYPE]: bindingType, + [CF_BINDING_NAME]: bindingName, + [CF_BINDING_METHOD]: methodName, + }; +} + /** * Add "cf.binding.error" attribute to a span * @@ -179,7 +206,7 @@ function proxyD1Binding(o: unknown, bindingName: string) { function handleError(span: Span, error: unknown) { const serializableError = error instanceof Error ? errorToJson(error) : error; const errorAttributes = { - "cf.binding.error": safelySerializeJSON(serializableError), + [CF_BINDING_ERROR]: safelySerializeJSON(serializableError), }; span.setAttributes(errorAttributes); } From 35ebebebcbed424b324597fee22f55a92c9677e7 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 28 Aug 2024 08:10:28 +0200 Subject: [PATCH 17/25] Fix ts-expect-error --- packages/client-library-otel/src/patch/cf-bindings.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/client-library-otel/src/patch/cf-bindings.ts b/packages/client-library-otel/src/patch/cf-bindings.ts index 6a6ff2925..d8bd55ba1 100644 --- a/packages/client-library-otel/src/patch/cf-bindings.ts +++ b/packages/client-library-otel/src/patch/cf-bindings.ts @@ -32,7 +32,6 @@ export function patchCloudflareBindings( continue; } - // @ts-expect-error - We know that env is a Record env[bindingName] = patchCloudflareBinding(envValue, bindingName); } } From 1f12c269036b92ead5ffc81274ba9e2a9a0c2c4e Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 28 Aug 2024 08:35:21 +0200 Subject: [PATCH 18/25] Try recording non-binary responses --- packages/client-library-otel/src/constants.ts | 1 + .../src/patch/cf-bindings.ts | 34 ++++++++++++++----- .../client-library-otel/src/utils/json.ts | 12 +++++++ 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/packages/client-library-otel/src/constants.ts b/packages/client-library-otel/src/constants.ts index cce6e98f7..a3edd3959 100644 --- a/packages/client-library-otel/src/constants.ts +++ b/packages/client-library-otel/src/constants.ts @@ -18,6 +18,7 @@ export const FPX_RESPONSE_BODY = "fpx.http.response.body"; export const CF_BINDING_TYPE = "cf.binding.type"; export const CF_BINDING_NAME = "cf.binding.name"; export const CF_BINDING_METHOD = "cf.binding.method"; +export const CF_BINDING_RESULT = "cf.binding.result"; export const CF_BINDING_ERROR = "cf.binding.error"; // NOT YET IMPLEMENTED diff --git a/packages/client-library-otel/src/patch/cf-bindings.ts b/packages/client-library-otel/src/patch/cf-bindings.ts index d8bd55ba1..37d5bbc38 100644 --- a/packages/client-library-otel/src/patch/cf-bindings.ts +++ b/packages/client-library-otel/src/patch/cf-bindings.ts @@ -3,10 +3,11 @@ import { CF_BINDING_ERROR, CF_BINDING_METHOD, CF_BINDING_NAME, + CF_BINDING_RESULT, CF_BINDING_TYPE, } from "../constants"; import { measure } from "../measure"; -import { errorToJson, safelySerializeJSON } from "../utils"; +import { errorToJson, isUintArray, safelySerializeJSON } from "../utils"; /** * A key used to mark objects as proxied by us, so that we don't proxy them again. @@ -90,11 +91,9 @@ function patchCloudflareBinding(o: object, bindingName: string) { args: safelySerializeJSON(args), }); }, - // TODO - Use this callback to add additional attributes to the span regarding the response... - // But the thing is, the result could be so wildly different depending on the method! - // Might be good to proxy each binding individually, eventually? - // - // onSuccess: (span, result) => {}, + onSuccess: (span, result) => { + addResultAttribute(span, result); + }, onError: handleError, }, // OPTIMIZE - bind is expensive, can we avoid it? @@ -157,8 +156,9 @@ function proxyD1Binding(o: object, bindingName: string) { args: safelySerializeJSON(args), }); }, - // TODO - Use this callback to add additional attributes to the span regarding the response. - // onSuccess: (span, result) => {}, + onSuccess: (span, result) => { + addResultAttribute(span, result); + }, onError: handleError, }, // OPTIMIZE - bind is expensive, can we avoid it? @@ -196,6 +196,24 @@ function getCfBindingAttributes( }; } +/** + * Add "cf.binding.result" attribute to a span + * + * @NOTE - The results of method calls could be so wildly different, and sometimes very large. + * We should be more careful here with what we attribute to the span. + * Also, might want to turn this off by default in production. + * + * @param span - The span to add the attribute to + * @param result - The result to add to the span + */ +function addResultAttribute(span: Span, result: unknown) { + // HACK - Probably a smarter way to avoid serlializing massive amounts of binary data, but this works for now + const isBinary = isUintArray(result); + span.setAttributes({ + [CF_BINDING_RESULT]: isBinary ? "binary" : safelySerializeJSON(result), + }); +} + /** * Add "cf.binding.error" attribute to a span * diff --git a/packages/client-library-otel/src/utils/json.ts b/packages/client-library-otel/src/utils/json.ts index f65d8385e..522df0960 100644 --- a/packages/client-library-otel/src/utils/json.ts +++ b/packages/client-library-otel/src/utils/json.ts @@ -1,6 +1,10 @@ export function safelySerializeJSON(obj: unknown): string { const seen = new WeakSet(); return JSON.stringify(obj, (_key, value) => { + // HACK - Do not serialize binary data - There is probably a smarter way to do this + if (isUintArray(value)) { + return "BINARY"; + } if (typeof value === "object" && value !== null) { if (seen.has(value)) { return "[Circular]"; @@ -10,3 +14,11 @@ export function safelySerializeJSON(obj: unknown): string { return value; }); } + +export function isUintArray(arr: unknown): arr is number[] { + return ( + arr instanceof Uint8Array || + arr instanceof Uint16Array || + arr instanceof Uint32Array + ); +} From 7731592294fc090ca782f9af3b282a974c3d3a6d Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 28 Aug 2024 08:47:47 +0200 Subject: [PATCH 19/25] Add a German Gans --- sample-apps/goosify/src/index.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/sample-apps/goosify/src/index.ts b/sample-apps/goosify/src/index.ts index 8b80fa899..409428868 100644 --- a/sample-apps/goosify/src/index.ts +++ b/sample-apps/goosify/src/index.ts @@ -45,6 +45,30 @@ app.get("/api/geese", async (c) => { return c.json({ geese }); }); +app.get("/api/Gans", async (c) => { + const prompt = c.req.query("prompt") || "What's happenin' Gans?"; + + const messages = [ + { + role: "system", + content: + "You are a friendly German Gans. You speak only of Geese. You speak only in German. You are a little grumpy", + }, + { + role: "user", + content: prompt, + }, + ]; + const response = await c.env.AI.run( + "@cf/thebloke/discolm-german-7b-v1-awq", + // NOTE - This is an issue with the types + // https://github.com/cloudflare/workerd/issues/2181 + { messages } as BaseAiTextGeneration["inputs"], + ); + + return c.json(response); +}); + // TODO app.get("/api/cyberpunk-goose", async (c) => { const inputs = { From 4974ff83d64b4d17a63ddd242cffb3928023331b Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 28 Aug 2024 11:06:28 +0200 Subject: [PATCH 20/25] Add showcase request generation app --- pnpm-lock.yaml | 23 +- .../ai-request-generation/.dev.vars.example | 1 + sample-apps/ai-request-generation/.gitignore | 33 +++ sample-apps/ai-request-generation/README.md | 26 ++ .../ai-request-generation/package.json | 19 ++ .../ai-request-generation/src/db/schema.ts | 10 + .../ai-request-generation/src/index.ts | 86 +++++++ .../ai-request-generation/src/prompts.ts | 241 ++++++++++++++++++ .../ai-request-generation/src/tools.ts | 62 +++++ .../ai-request-generation/tsconfig.json | 17 ++ .../ai-request-generation/wrangler.toml | 21 ++ sample-apps/goose-quotes/package.json | 2 +- sample-apps/goosify/README.md | 10 +- 13 files changed, 544 insertions(+), 7 deletions(-) create mode 100644 sample-apps/ai-request-generation/.dev.vars.example create mode 100644 sample-apps/ai-request-generation/.gitignore create mode 100644 sample-apps/ai-request-generation/README.md create mode 100644 sample-apps/ai-request-generation/package.json create mode 100644 sample-apps/ai-request-generation/src/db/schema.ts create mode 100644 sample-apps/ai-request-generation/src/index.ts create mode 100644 sample-apps/ai-request-generation/src/prompts.ts create mode 100644 sample-apps/ai-request-generation/src/tools.ts create mode 100644 sample-apps/ai-request-generation/tsconfig.json create mode 100644 sample-apps/ai-request-generation/wrangler.toml diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ed60bfe2..5b05c8ce8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,25 @@ importers: specifier: ^3.23.8 version: 3.23.8 + sample-apps/ai-request-generation: + dependencies: + '@fiberplane/hono-otel': + specifier: workspace:* + version: link:../../packages/client-library-otel + '@langchain/core': + specifier: ^0.2.18 + version: 0.2.23(openai@4.55.4(encoding@0.1.13)(zod@3.23.8)) + hono: + specifier: ^4.5.9 + version: 4.5.9 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20240821.1 + version: 4.20240821.1 + wrangler: + specifier: ^3.72.2 + version: 3.72.2(@cloudflare/workers-types@4.20240821.1) + sample-apps/goose-quotes: dependencies: '@fiberplane/hono-otel': @@ -194,8 +213,8 @@ importers: specifier: ^0.32.0 version: 0.32.2(@cloudflare/workers-types@4.20240821.1)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1) hono: - specifier: ^4.5.1 - version: 4.5.5 + specifier: ^4.5.9 + version: 4.5.9 openai: specifier: ^4.53.0 version: 4.55.4(encoding@0.1.13)(zod@3.23.8) diff --git a/sample-apps/ai-request-generation/.dev.vars.example b/sample-apps/ai-request-generation/.dev.vars.example new file mode 100644 index 000000000..56568de93 --- /dev/null +++ b/sample-apps/ai-request-generation/.dev.vars.example @@ -0,0 +1 @@ +FPX_ENDPOINT=http://localhost:8788/v1/traces \ No newline at end of file diff --git a/sample-apps/ai-request-generation/.gitignore b/sample-apps/ai-request-generation/.gitignore new file mode 100644 index 000000000..e319e0635 --- /dev/null +++ b/sample-apps/ai-request-generation/.gitignore @@ -0,0 +1,33 @@ +# prod +dist/ + +# dev +.yarn/ +!.yarn/releases +.vscode/* +!.vscode/launch.json +!.vscode/*.code-snippets +.idea/workspace.xml +.idea/usage.statistics.xml +.idea/shelf + +# deps +node_modules/ +.wrangler + +# env +.env +.env.production +.dev.vars + +# logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# misc +.DS_Store diff --git a/sample-apps/ai-request-generation/README.md b/sample-apps/ai-request-generation/README.md new file mode 100644 index 000000000..5664fd559 --- /dev/null +++ b/sample-apps/ai-request-generation/README.md @@ -0,0 +1,26 @@ +# Request Parameter Generation with Hermes + +This example shows how to use a Cloudflare Workers AI model that supports function calling in order to generate request parameters for a Hono route. It uses the [Hermes](https://huggingface.co/nousresearch/hermes-2-pro-mistral-7b) model, which is a Mistral-based model that supports function calling. + +Fiberplane Studio is used to add timing information to the request. Instrumentation of the Cloudflare `AI` binding should happen automagically. + +## Running Locally + +You will need a Cloudflare account in order to run this locally, since AI inference is billed. + +```sh +pnpm i +pnpm dev +``` + +Then, you can inspect the request and response in Fiberplane Studio. + +```sh +npx @fiberplane/studio +``` + +Use the following request body against the ___ route + +```json + +``` \ No newline at end of file diff --git a/sample-apps/ai-request-generation/package.json b/sample-apps/ai-request-generation/package.json new file mode 100644 index 000000000..2c9145e3c --- /dev/null +++ b/sample-apps/ai-request-generation/package.json @@ -0,0 +1,19 @@ +{ + "name": "request-generation-showcase", + "scripts": { + "dev": "wrangler dev src/index.ts", + "deploy": "wrangler deploy --minify src/index.ts", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx migrate.ts", + "db:seed": "tsx seed.ts" + }, + "dependencies": { + "@fiberplane/hono-otel": "workspace:*", + "@langchain/core": "^0.2.18", + "hono": "^4.5.9" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240821.1", + "wrangler": "^3.72.2" + } +} diff --git a/sample-apps/ai-request-generation/src/db/schema.ts b/sample-apps/ai-request-generation/src/db/schema.ts new file mode 100644 index 000000000..4a9a5e34a --- /dev/null +++ b/sample-apps/ai-request-generation/src/db/schema.ts @@ -0,0 +1,10 @@ +import { pgTable, serial, text, jsonb, timestamp } from 'drizzle-orm/pg-core'; + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + name: text('name'), + email: text('email'), + settings: jsonb('settings'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); \ No newline at end of file diff --git a/sample-apps/ai-request-generation/src/index.ts b/sample-apps/ai-request-generation/src/index.ts new file mode 100644 index 000000000..310e6c323 --- /dev/null +++ b/sample-apps/ai-request-generation/src/index.ts @@ -0,0 +1,86 @@ +import { Hono } from 'hono'; +import { instrument } from '@fiberplane/hono-otel'; +import { makeRequestToolHermes } from './tools'; +import { getSystemPrompt } from './prompts'; + +type Bindings = { + DATABASE_URL: string; + // Cloudflare Workers AI binding + // enabled in wrangler.toml with: + // + // > [ai] + // > binding = "AI" + AI: Ai; +}; + +const app = new Hono<{ Bindings: Bindings }>() + +app.get('/', async (c) => { + const inferenceResult = await runInference(c.env.AI, "/users/:id") + + // We are not using streaming outputs, but just in case, handle the stream here + if (inferenceResult instanceof ReadableStream) { + return c.json({ + message: "Unexpected inference result (stream)", + }, 500) + } + + // We are theoretically enforcing a tool call, so this should not happen + if (inferenceResult.response != null) { + return c.json({ + message: "Unexpected inference result (text)", + }, 500) + } + + // Parse the tool call + const makeRequestCall = inferenceResult.tool_calls?.[0]; + const requestDescriptor = makeRequestCall?.arguments; + + // TODO - Validate the request descriptor against the JSON Schema from the tool definition + if (!isObjectGuard(requestDescriptor)) { + return c.json({ + message: "Invalid request descriptor" + }, 500) + } + + console.log("requestDescriptor", JSON.stringify(requestDescriptor, null, 2)); + + return c.json(requestDescriptor) +}) + +export default instrument(app); + +export async function runInference(client: Ai, userPrompt: string) { + const result = await client.run( + // @ts-ignore - This model exists in the Worker types as far as I can tell + // I don't know why it's causing a typescript error here :( + "@hf/nousresearch/hermes-2-pro-mistral-7b", + { + tools: [makeRequestToolHermes], + // Restrict to only using this "make request" tool + tool_choice: { type: "function", function: { name: makeRequestToolHermes.name } }, + + messages: [ + { + role: "system", + content: getSystemPrompt("QA"), + }, + // TODO - File issue on the Cloudflare docs repo + // Since this example did not work! + // + // { + // role: "user", + // content: userPrompt, + // }, + ], + temperature: 0.12, + + // NOTE - The request will fail if you don't put the prompt here + prompt: userPrompt, + }) + + // HACK - Need to coerce this to a AiTextGenerationOutput + return result as AiTextGenerationOutput; +} + +const isObjectGuard = (value: unknown): value is object => typeof value === 'object' && value !== null; diff --git a/sample-apps/ai-request-generation/src/prompts.ts b/sample-apps/ai-request-generation/src/prompts.ts new file mode 100644 index 000000000..05bab387f --- /dev/null +++ b/sample-apps/ai-request-generation/src/prompts.ts @@ -0,0 +1,241 @@ +import { PromptTemplate } from "@langchain/core/prompts"; + +export const getSystemPrompt = (persona: string) => { + return persona === "QA" + ? QA_PARAMETER_GENERATION_SYSTEM_PROMPT + : FRIENDLY_PARAMETER_GENERATION_SYSTEM_PROMPT; +}; + +export const invokeRequestGenerationPrompt = async ({ + persona, + method, + path, + handler, + history, + openApiSpec, +}: { + persona: string; + method: string; + path: string; + handler: string; + history?: Array; + openApiSpec?: string; +}) => { + const promptTemplate = + persona === "QA" ? qaTesterPrompt : friendlyTesterPrompt; + const userPromptInterface = await promptTemplate.invoke({ + method, + path, + handler, + history: history?.join("\n") ?? "NO HISTORY", + openApiSpec: openApiSpec ?? "NO OPENAPI SPEC", + }); + const userPrompt = userPromptInterface.value; + return userPrompt; +}; + +/** + * A friendly tester prompt. + * + * This prompt is used to generate requests for the API. + * It is a friendly tester, who tries to help you succeed. + */ +export const friendlyTesterPrompt = PromptTemplate.fromTemplate( + ` +I need to make a request to one of my Hono api handlers. + +Here are some recent requests/responses, which you can use as inspiration for future requests. +E.g., if we recently created a resource, you can look that resource up. + + +{history} + + +The request you make should be a {method} request to route: {path} + +Here is the OpenAPI spec for the handler: +{openApiSpec} + +Here is the code for the handler: +{handler} +`.trim(), +); + +// NOTE - We need to remind the QA tester not to generate long inputs, +// since that has (in the past) broken tool calling with gpt-4o +export const qaTesterPrompt = PromptTemplate.fromTemplate( + ` +I need to make a request to one of my Hono api handlers. + +Here are some recent requests and responses, which you can use as inspiration for future requests. + + +{history} + + +The request you make should be a {method} request to route: {path} + +Here is the OpenAPI spec for the handler: +{openApiSpec} + +Here is the code for the handler: +{handler} + +REMEMBER YOU ARE A QA. MISUSE THE API. BUT DO NOT MISUSE YOURSELF. +Keep your responses short-ish. Including your random data. +`.trim(), +); + +export const FRIENDLY_PARAMETER_GENERATION_SYSTEM_PROMPT = cleanPrompt(` +You are a friendly, expert full-stack engineer and an API testing assistant for apps that use Hono, +a typescript web framework similar to express. + +You need to help craft requests to route handlers. + +You will be provided the source code of a route handler for an API route, and you should generate +query parameters, a request body, and headers that will test the request. + +Be clever and creative with test data. Avoid just writing things like "test". + +For example, if you get a route like \`/users/:id\`, you should return a URL like +\`/users/10\` and a pathParams parameter like this: + +{ "path": "/users/10", "pathParams": { "key": ":id", "value": "10" } } + +*Remember to keep the colon in the pathParam key!* + +If you get a route like \`POST /users/:id\` with a handler like: + +\`\`\`ts +async (c) => { + const token = c.req.headers.get("authorization")?.split(" ")[1] + + const auth = c.get("authService"); + const isAuthorized = await auth.isAuthorized(token) + if (!isAuthorized) { + return c.json({ message: "Unauthorized" }, 401) + } + + const db = c.get("db"); + + const id = c.req.param('id'); + const { email } = await c.req.json() + + const user = (await db.update(user).set({ email }).where(eq(user.id, +id)).returning())?.[0]; + + if (!user) { + return c.json({ message: 'User not found' }, 404); + } + + return c.json(user); +} +\`\`\` + +You should return a URL like: + +\`/users/64\` and a pathParams like: + +{ "path": "/users/64", "pathParams": { "key": ":id", "value": "64" } } + +and a header like: + +{ "headers": { "key": "authorization", "value": "Bearer " } } + +and a body like: + +{ email: "paul@beatles.music" } + +=== + +Use the tool "make_request". Always respond in valid JSON. Help the user test the happy path. +`); + +/** + * A QA (hostile) tester prompt. + * + * This prompt is used to generate requests for the API. + * It is a QA tester, who tries to break your api. + * + * NOTE - I had to stop instructing the AI to create very long data in this prompt. + * It would end up repeating 9999999 ad infinitum and break JSON responses. + */ +export const QA_PARAMETER_GENERATION_SYSTEM_PROMPT = cleanPrompt(` +You are an expert QA Engineer, a thorough API tester, and a code debugging assistant for web APIs that use Hono, +a typescript web framework similar to express. You have a generally hostile disposition. + +You need to help craft requests to route handlers. + +You will be provided the source code of a route handler for an API route, and you should generate +query parameters, a request body, and headers that will test the request. + +Be clever and creative with test data. Avoid just writing things like "test". + +For example, if you get a route like \`/users/:id\`, you should return a filled-in "path" field, +like \`/users/1234567890\` and a "pathParams" field like: + +{ "path": "/users/1234567890", "pathParams": { "key": ":id", "value": "1234567890" } } + +*Remember to keep the colon in the pathParam key!* + +If you get a route like \`POST /users/:id\` with a handler like: + +\`\`\`ts +async (c) => { + const token = c.req.headers.get("authorization")?.split(" ")[1] + + const auth = c.get("authService"); + const isAuthorized = await auth.isAuthorized(token) + if (!isAuthorized) { + return c.json({ message: "Unauthorized" }, 401) + } + + const db = c.get("db"); + + const id = c.req.param('id'); + const { email } = await c.req.json() + + const user = (await db.update(user).set({ email }).where(eq(user.id, +id)).returning())?.[0]; + + if (!user) { + return c.json({ message: 'User not found' }, 404); + } + + return c.json(user); +} +\`\`\` + +You should return a filled-in "path" field like \`/users/1234567890\` and a "pathParams" field like: + +{ "path": "/users/1234567890", "pathParams": { "key": ":id", "value": "1234567890" } } + +and a header like: + +{ "headers": { "key": "authorization", "value": "Bearer admin" } } + +and a body like: + +{ "body": { "email": "" } } + +You should focus on trying to break things. You are a QA. + +You are the enemy of bugs. To protect quality, you must find bugs. + +Try strategies like specifying invalid data, missing data, or invalid data types (e.g., using strings instead of numbers). + +Try to break the system. But do not break yourself! +Keep your responses to a reasonable length. Including your random data. + +Use the tool "make_request". Always respond in valid JSON. +***Don't make your responses too long, otherwise we cannot parse your JSON response.*** +`); + +/** + * Clean a prompt by trimming whitespace for each line and joining the lines. + */ +export function cleanPrompt(prompt: string) { + return prompt + .trim() + .split("\n") + .map((l) => l.trim()) + .join("\n"); +} diff --git a/sample-apps/ai-request-generation/src/tools.ts b/sample-apps/ai-request-generation/src/tools.ts new file mode 100644 index 000000000..bfda8fd95 --- /dev/null +++ b/sample-apps/ai-request-generation/src/tools.ts @@ -0,0 +1,62 @@ +// https://developers.cloudflare.com/workers-ai/function-calling/ +export const makeRequestToolHermes = { + name: "make_request", + description: + "Generates some random data for an http request to an api backend", + // Describe parameters as json schema https://json-schema.org/understanding-json-schema/ + parameters: { + type: "object" as const, + properties: { + path: { + type: "string" as const, + }, + pathParams: { + type: "array" as const, + items: { + type: "object" as const, + properties: { + key: { + type: "string", + }, + value: { + type: "string", + }, + }, + }, + }, + queryParams: { + type: "array" as const, + items: { + type: "object" as const, + properties: { + key: { + type: "string" as const, + }, + value: { + type: "string" as const, + }, + }, + }, + }, + body: { + type: "string" as const, + }, + headers: { + type: "array" as const, + items: { + type: "object" as const, + properties: { + key: { + type: "string" as const, + }, + value: { + type: "string" as const, + }, + }, + }, + }, + }, + // TODO - Mark fields like `pathParams` as required based on the route definition? + required: ["path"], + }, +}; diff --git a/sample-apps/ai-request-generation/tsconfig.json b/sample-apps/ai-request-generation/tsconfig.json new file mode 100644 index 000000000..51a889c93 --- /dev/null +++ b/sample-apps/ai-request-generation/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": [ + "ESNext" + ], + "types": [ + "@cloudflare/workers-types/2023-07-01" + ], + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx" + } +} \ No newline at end of file diff --git a/sample-apps/ai-request-generation/wrangler.toml b/sample-apps/ai-request-generation/wrangler.toml new file mode 100644 index 000000000..acfdaae89 --- /dev/null +++ b/sample-apps/ai-request-generation/wrangler.toml @@ -0,0 +1,21 @@ +name = "showcase-request-gen" +compatibility_date = "2024-08-27" + +[ai] +binding = "AI" + +# [vars] +# MY_VAR = "my-variable" + +# [[kv_namespaces]] +# binding = "MY_KV_NAMESPACE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# [[r2_buckets]] +# binding = "MY_BUCKET" +# bucket_name = "my-bucket" + +# [[d1_databases]] +# binding = "DB" +# database_name = "my-database" +# database_id = "" diff --git a/sample-apps/goose-quotes/package.json b/sample-apps/goose-quotes/package.json index 7809d15c4..45a8e83c9 100644 --- a/sample-apps/goose-quotes/package.json +++ b/sample-apps/goose-quotes/package.json @@ -11,7 +11,7 @@ "@neondatabase/serverless": "^0.9.4", "dotenv": "^16.4.5", "drizzle-orm": "^0.32.0", - "hono": "^4.5.1", + "hono": "^4.5.9", "openai": "^4.53.0" }, "devDependencies": { diff --git a/sample-apps/goosify/README.md b/sample-apps/goosify/README.md index 2e8486efe..8b4f5834f 100644 --- a/sample-apps/goosify/README.md +++ b/sample-apps/goosify/README.md @@ -18,10 +18,6 @@ There is some plumbing in place to deploy to prod, but that's not what this is m You'll need a CF account to run this with the AI binding. ## Commands -```sh -pnpm i -pnpm dev -``` ```sh # HACK - This script initializes a D1 database *locally* so that we can mess with it @@ -29,3 +25,9 @@ pnpm db:touch pnpm db:generate pnpm db:migrate ``` + +```sh +pnpm i +pnpm dev +``` + From 3a3c8c16e9c281715e5eccb23b469431513a64d2 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 28 Aug 2024 11:16:52 +0200 Subject: [PATCH 21/25] Add function calling cloudflare ai example --- pnpm-lock.yaml | 26 +++++++++---------- sample-apps/ai-request-generation/README.md | 12 ++++++++- .../ai-request-generation/package.json | 7 ++--- .../ai-request-generation/src/index.ts | 11 +++++--- .../ai-request-generation/wrangler.toml | 1 + sample-apps/goose-quotes/package.json | 2 +- sample-apps/goosify/package.json | 2 +- 7 files changed, 36 insertions(+), 25 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b05c8ce8..0a6f8cf8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,8 +195,8 @@ importers: specifier: ^4.20240821.1 version: 4.20240821.1 wrangler: - specifier: ^3.72.2 - version: 3.72.2(@cloudflare/workers-types@4.20240821.1) + specifier: ^3.72.3 + version: 3.72.3(@cloudflare/workers-types@4.20240821.1) sample-apps/goose-quotes: dependencies: @@ -226,8 +226,8 @@ importers: specifier: ^0.23.0 version: 0.23.2 wrangler: - specifier: ^3.72.2 - version: 3.72.2(@cloudflare/workers-types@4.20240821.1) + specifier: ^3.72.3 + version: 3.72.3(@cloudflare/workers-types@4.20240821.1) sample-apps/goosify: dependencies: @@ -245,8 +245,8 @@ importers: specifier: ^4.20240821.1 version: 4.20240821.1 wrangler: - specifier: ^3.72.2 - version: 3.72.2(@cloudflare/workers-types@4.20240821.1) + specifier: ^3.72.3 + version: 3.72.3(@cloudflare/workers-types@4.20240821.1) studio: dependencies: @@ -798,8 +798,8 @@ packages: '@cloudflare/workers-shared@0.1.0': resolution: {integrity: sha512-SyD4iw6jM4anZaG+ujgVETV4fulF2KHBOW31eavbVN7TNpk2l4aJgwY1YSPK00IKSWsoQuH2TigR446KuT5lqQ==} - '@cloudflare/workers-shared@0.3.0': - resolution: {integrity: sha512-cqtLW1QiBC/ABaZIhAdyGCsnHHY6pAb6hsVUZg82Co2gQtf/faxRYV1FgpCwUYroTdk6A66xUMSTmFqreKCJow==} + '@cloudflare/workers-shared@0.4.0': + resolution: {integrity: sha512-XAFOldVQsbxQ7mjbqX2q1dNIgcLbKSytk41pwuZTn9e0p7OeTpFTosJef8uwosL6CcOAHqcW1f1HJxyjwmtGxw==} engines: {node: '>=16.7.0'} '@cloudflare/workers-types@4.20240806.0': @@ -6987,8 +6987,8 @@ packages: '@cloudflare/workers-types': optional: true - wrangler@3.72.2: - resolution: {integrity: sha512-7nxkJ4md+KtESNJ/0DwTM7bHZP+uNRpJT5gMDT9WllP9UVzYdtXCTF+p4CHtxIReUpe6pOi7tb05hK9/Q6WaiA==} + wrangler@3.72.3: + resolution: {integrity: sha512-EBlJGOcwanbzFkiJkRB47WKhvevh1AZK0ty0MyD0gptsgWnAxBfmFGiBuzOuRXbvH45ZrFrTqgi8c67EwcV1nA==} engines: {node: '>=16.17.0'} hasBin: true peerDependencies: @@ -7448,7 +7448,7 @@ snapshots: '@cloudflare/workers-shared@0.1.0': {} - '@cloudflare/workers-shared@0.3.0': {} + '@cloudflare/workers-shared@0.4.0': {} '@cloudflare/workers-types@4.20240806.0': {} @@ -14085,10 +14085,10 @@ snapshots: - supports-color - utf-8-validate - wrangler@3.72.2(@cloudflare/workers-types@4.20240821.1): + wrangler@3.72.3(@cloudflare/workers-types@4.20240821.1): dependencies: '@cloudflare/kv-asset-handler': 0.3.4 - '@cloudflare/workers-shared': 0.3.0 + '@cloudflare/workers-shared': 0.4.0 '@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19) '@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19) blake3-wasm: 2.1.5 diff --git a/sample-apps/ai-request-generation/README.md b/sample-apps/ai-request-generation/README.md index 5664fd559..bb8ceda54 100644 --- a/sample-apps/ai-request-generation/README.md +++ b/sample-apps/ai-request-generation/README.md @@ -19,8 +19,18 @@ Then, you can inspect the request and response in Fiberplane Studio. npx @fiberplane/studio ``` -Use the following request body against the ___ route +Test one of the following JSON request bodies against the `POST /` route, and you'll see structured output describing a sample HTTP request. + +You can adjust query parameters like `temperature` in the request query params. ```json +{ + "prompt": "GET /users/:id" +} +``` +```json +{ + "prompt": "GET /users/:id" +} ``` \ No newline at end of file diff --git a/sample-apps/ai-request-generation/package.json b/sample-apps/ai-request-generation/package.json index 2c9145e3c..ab7b579c1 100644 --- a/sample-apps/ai-request-generation/package.json +++ b/sample-apps/ai-request-generation/package.json @@ -2,10 +2,7 @@ "name": "request-generation-showcase", "scripts": { "dev": "wrangler dev src/index.ts", - "deploy": "wrangler deploy --minify src/index.ts", - "db:generate": "drizzle-kit generate", - "db:migrate": "tsx migrate.ts", - "db:seed": "tsx seed.ts" + "deploy": "wrangler deploy --minify src/index.ts" }, "dependencies": { "@fiberplane/hono-otel": "workspace:*", @@ -14,6 +11,6 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20240821.1", - "wrangler": "^3.72.2" + "wrangler": "^3.72.3" } } diff --git a/sample-apps/ai-request-generation/src/index.ts b/sample-apps/ai-request-generation/src/index.ts index 310e6c323..0d8b82252 100644 --- a/sample-apps/ai-request-generation/src/index.ts +++ b/sample-apps/ai-request-generation/src/index.ts @@ -15,8 +15,11 @@ type Bindings = { const app = new Hono<{ Bindings: Bindings }>() -app.get('/', async (c) => { - const inferenceResult = await runInference(c.env.AI, "/users/:id") +app.post('/', async (c) => { + const strTemperature = c.req.query('temperature') ?? "0.12"; + const temperature = Number.parseFloat(strTemperature) ?? 0.12; + const body = await c.req.json(); + const inferenceResult = await runInference(c.env.AI, body.prompt, temperature) // We are not using streaming outputs, but just in case, handle the stream here if (inferenceResult instanceof ReadableStream) { @@ -50,7 +53,7 @@ app.get('/', async (c) => { export default instrument(app); -export async function runInference(client: Ai, userPrompt: string) { +export async function runInference(client: Ai, userPrompt: string, temperature: number) { const result = await client.run( // @ts-ignore - This model exists in the Worker types as far as I can tell // I don't know why it's causing a typescript error here :( @@ -73,7 +76,7 @@ export async function runInference(client: Ai, userPrompt: string) { // content: userPrompt, // }, ], - temperature: 0.12, + temperature, // NOTE - The request will fail if you don't put the prompt here prompt: userPrompt, diff --git a/sample-apps/ai-request-generation/wrangler.toml b/sample-apps/ai-request-generation/wrangler.toml index acfdaae89..a88f101cf 100644 --- a/sample-apps/ai-request-generation/wrangler.toml +++ b/sample-apps/ai-request-generation/wrangler.toml @@ -1,5 +1,6 @@ name = "showcase-request-gen" compatibility_date = "2024-08-27" +compatibility_flags = ["nodejs_compat"] [ai] binding = "AI" diff --git a/sample-apps/goose-quotes/package.json b/sample-apps/goose-quotes/package.json index 45a8e83c9..70469721c 100644 --- a/sample-apps/goose-quotes/package.json +++ b/sample-apps/goose-quotes/package.json @@ -17,7 +17,7 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20240821.1", "drizzle-kit": "^0.23.0", - "wrangler": "^3.72.2" + "wrangler": "^3.72.3" }, "homepage": "https://github.com/fiberplane/fpx/sample-apps/goose-quotes#readme" } diff --git a/sample-apps/goosify/package.json b/sample-apps/goosify/package.json index 437133636..801236628 100644 --- a/sample-apps/goosify/package.json +++ b/sample-apps/goosify/package.json @@ -17,6 +17,6 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20240821.1", - "wrangler": "^3.72.2" + "wrangler": "^3.72.3" } } From 70356088ee91fce344166232d9544387b000b3cd Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 28 Aug 2024 11:39:41 +0200 Subject: [PATCH 22/25] Rename sample-apps to examples --- .../ai-request-generation/.dev.vars.example | 0 .../ai-request-generation/.gitignore | 0 .../ai-request-generation/README.md | 0 .../ai-request-generation/package.json | 0 .../ai-request-generation/src/db/schema.ts | 0 .../ai-request-generation/src/index.ts | 0 .../ai-request-generation/src/prompts.ts | 0 .../ai-request-generation/src/tools.ts | 0 .../ai-request-generation/tsconfig.json | 0 .../ai-request-generation/wrangler.toml | 0 .../goose-quotes/.dev.vars.example | 0 .../goose-quotes/.gitignore | 0 .../goose-quotes/README.md | 0 .../goose-quotes/drizzle.config.ts | 0 .../drizzle/0000_talented_the_watchers.sql | 0 .../drizzle/meta/0000_snapshot.json | 0 .../goose-quotes/drizzle/meta/_journal.json | 0 .../goose-quotes/package.json | 2 +- .../goose-quotes/src/db/schema.ts | 0 .../goose-quotes/src/index.ts | 0 .../goose-quotes/tsconfig.json | 0 .../goose-quotes/wrangler.toml | 0 .../goosify/.dev.vars.example | 0 {sample-apps => examples}/goosify/.gitignore | 0 .../goosify/.prod.vars.example | 0 {sample-apps => examples}/goosify/README.md | 0 .../goosify/db-touch.sql | 0 .../goosify/drizzle.config.ts | 0 .../migrations/0000_new_human_robot.sql | 0 .../migrations/0001_omniscient_silver_fox.sql | 0 .../migrations/meta/0000_snapshot.json | 0 .../migrations/meta/0001_snapshot.json | 0 .../drizzle/migrations/meta/_journal.json | 0 .../goosify/package.json | 0 .../goosify/src/db/schema.ts | 0 .../goosify/src/index.ts | 0 .../goosify/tsconfig.json | 0 .../goosify/wrangler.toml | 0 pnpm-lock.yaml | 134 +++++++++--------- pnpm-workspace.yaml | 2 +- 40 files changed, 69 insertions(+), 69 deletions(-) rename {sample-apps => examples}/ai-request-generation/.dev.vars.example (100%) rename {sample-apps => examples}/ai-request-generation/.gitignore (100%) rename {sample-apps => examples}/ai-request-generation/README.md (100%) rename {sample-apps => examples}/ai-request-generation/package.json (100%) rename {sample-apps => examples}/ai-request-generation/src/db/schema.ts (100%) rename {sample-apps => examples}/ai-request-generation/src/index.ts (100%) rename {sample-apps => examples}/ai-request-generation/src/prompts.ts (100%) rename {sample-apps => examples}/ai-request-generation/src/tools.ts (100%) rename {sample-apps => examples}/ai-request-generation/tsconfig.json (100%) rename {sample-apps => examples}/ai-request-generation/wrangler.toml (100%) rename {sample-apps => examples}/goose-quotes/.dev.vars.example (100%) rename {sample-apps => examples}/goose-quotes/.gitignore (100%) rename {sample-apps => examples}/goose-quotes/README.md (100%) rename {sample-apps => examples}/goose-quotes/drizzle.config.ts (100%) rename {sample-apps => examples}/goose-quotes/drizzle/0000_talented_the_watchers.sql (100%) rename {sample-apps => examples}/goose-quotes/drizzle/meta/0000_snapshot.json (100%) rename {sample-apps => examples}/goose-quotes/drizzle/meta/_journal.json (100%) rename {sample-apps => examples}/goose-quotes/package.json (87%) rename {sample-apps => examples}/goose-quotes/src/db/schema.ts (100%) rename {sample-apps => examples}/goose-quotes/src/index.ts (100%) rename {sample-apps => examples}/goose-quotes/tsconfig.json (100%) rename {sample-apps => examples}/goose-quotes/wrangler.toml (100%) rename {sample-apps => examples}/goosify/.dev.vars.example (100%) rename {sample-apps => examples}/goosify/.gitignore (100%) rename {sample-apps => examples}/goosify/.prod.vars.example (100%) rename {sample-apps => examples}/goosify/README.md (100%) rename {sample-apps => examples}/goosify/db-touch.sql (100%) rename {sample-apps => examples}/goosify/drizzle.config.ts (100%) rename {sample-apps => examples}/goosify/drizzle/migrations/0000_new_human_robot.sql (100%) rename {sample-apps => examples}/goosify/drizzle/migrations/0001_omniscient_silver_fox.sql (100%) rename {sample-apps => examples}/goosify/drizzle/migrations/meta/0000_snapshot.json (100%) rename {sample-apps => examples}/goosify/drizzle/migrations/meta/0001_snapshot.json (100%) rename {sample-apps => examples}/goosify/drizzle/migrations/meta/_journal.json (100%) rename {sample-apps => examples}/goosify/package.json (100%) rename {sample-apps => examples}/goosify/src/db/schema.ts (100%) rename {sample-apps => examples}/goosify/src/index.ts (100%) rename {sample-apps => examples}/goosify/tsconfig.json (100%) rename {sample-apps => examples}/goosify/wrangler.toml (100%) diff --git a/sample-apps/ai-request-generation/.dev.vars.example b/examples/ai-request-generation/.dev.vars.example similarity index 100% rename from sample-apps/ai-request-generation/.dev.vars.example rename to examples/ai-request-generation/.dev.vars.example diff --git a/sample-apps/ai-request-generation/.gitignore b/examples/ai-request-generation/.gitignore similarity index 100% rename from sample-apps/ai-request-generation/.gitignore rename to examples/ai-request-generation/.gitignore diff --git a/sample-apps/ai-request-generation/README.md b/examples/ai-request-generation/README.md similarity index 100% rename from sample-apps/ai-request-generation/README.md rename to examples/ai-request-generation/README.md diff --git a/sample-apps/ai-request-generation/package.json b/examples/ai-request-generation/package.json similarity index 100% rename from sample-apps/ai-request-generation/package.json rename to examples/ai-request-generation/package.json diff --git a/sample-apps/ai-request-generation/src/db/schema.ts b/examples/ai-request-generation/src/db/schema.ts similarity index 100% rename from sample-apps/ai-request-generation/src/db/schema.ts rename to examples/ai-request-generation/src/db/schema.ts diff --git a/sample-apps/ai-request-generation/src/index.ts b/examples/ai-request-generation/src/index.ts similarity index 100% rename from sample-apps/ai-request-generation/src/index.ts rename to examples/ai-request-generation/src/index.ts diff --git a/sample-apps/ai-request-generation/src/prompts.ts b/examples/ai-request-generation/src/prompts.ts similarity index 100% rename from sample-apps/ai-request-generation/src/prompts.ts rename to examples/ai-request-generation/src/prompts.ts diff --git a/sample-apps/ai-request-generation/src/tools.ts b/examples/ai-request-generation/src/tools.ts similarity index 100% rename from sample-apps/ai-request-generation/src/tools.ts rename to examples/ai-request-generation/src/tools.ts diff --git a/sample-apps/ai-request-generation/tsconfig.json b/examples/ai-request-generation/tsconfig.json similarity index 100% rename from sample-apps/ai-request-generation/tsconfig.json rename to examples/ai-request-generation/tsconfig.json diff --git a/sample-apps/ai-request-generation/wrangler.toml b/examples/ai-request-generation/wrangler.toml similarity index 100% rename from sample-apps/ai-request-generation/wrangler.toml rename to examples/ai-request-generation/wrangler.toml diff --git a/sample-apps/goose-quotes/.dev.vars.example b/examples/goose-quotes/.dev.vars.example similarity index 100% rename from sample-apps/goose-quotes/.dev.vars.example rename to examples/goose-quotes/.dev.vars.example diff --git a/sample-apps/goose-quotes/.gitignore b/examples/goose-quotes/.gitignore similarity index 100% rename from sample-apps/goose-quotes/.gitignore rename to examples/goose-quotes/.gitignore diff --git a/sample-apps/goose-quotes/README.md b/examples/goose-quotes/README.md similarity index 100% rename from sample-apps/goose-quotes/README.md rename to examples/goose-quotes/README.md diff --git a/sample-apps/goose-quotes/drizzle.config.ts b/examples/goose-quotes/drizzle.config.ts similarity index 100% rename from sample-apps/goose-quotes/drizzle.config.ts rename to examples/goose-quotes/drizzle.config.ts diff --git a/sample-apps/goose-quotes/drizzle/0000_talented_the_watchers.sql b/examples/goose-quotes/drizzle/0000_talented_the_watchers.sql similarity index 100% rename from sample-apps/goose-quotes/drizzle/0000_talented_the_watchers.sql rename to examples/goose-quotes/drizzle/0000_talented_the_watchers.sql diff --git a/sample-apps/goose-quotes/drizzle/meta/0000_snapshot.json b/examples/goose-quotes/drizzle/meta/0000_snapshot.json similarity index 100% rename from sample-apps/goose-quotes/drizzle/meta/0000_snapshot.json rename to examples/goose-quotes/drizzle/meta/0000_snapshot.json diff --git a/sample-apps/goose-quotes/drizzle/meta/_journal.json b/examples/goose-quotes/drizzle/meta/_journal.json similarity index 100% rename from sample-apps/goose-quotes/drizzle/meta/_journal.json rename to examples/goose-quotes/drizzle/meta/_journal.json diff --git a/sample-apps/goose-quotes/package.json b/examples/goose-quotes/package.json similarity index 87% rename from sample-apps/goose-quotes/package.json rename to examples/goose-quotes/package.json index 70469721c..3a2c57081 100644 --- a/sample-apps/goose-quotes/package.json +++ b/examples/goose-quotes/package.json @@ -19,5 +19,5 @@ "drizzle-kit": "^0.23.0", "wrangler": "^3.72.3" }, - "homepage": "https://github.com/fiberplane/fpx/sample-apps/goose-quotes#readme" + "homepage": "https://github.com/fiberplane/fpx/examples/goose-quotes#readme" } diff --git a/sample-apps/goose-quotes/src/db/schema.ts b/examples/goose-quotes/src/db/schema.ts similarity index 100% rename from sample-apps/goose-quotes/src/db/schema.ts rename to examples/goose-quotes/src/db/schema.ts diff --git a/sample-apps/goose-quotes/src/index.ts b/examples/goose-quotes/src/index.ts similarity index 100% rename from sample-apps/goose-quotes/src/index.ts rename to examples/goose-quotes/src/index.ts diff --git a/sample-apps/goose-quotes/tsconfig.json b/examples/goose-quotes/tsconfig.json similarity index 100% rename from sample-apps/goose-quotes/tsconfig.json rename to examples/goose-quotes/tsconfig.json diff --git a/sample-apps/goose-quotes/wrangler.toml b/examples/goose-quotes/wrangler.toml similarity index 100% rename from sample-apps/goose-quotes/wrangler.toml rename to examples/goose-quotes/wrangler.toml diff --git a/sample-apps/goosify/.dev.vars.example b/examples/goosify/.dev.vars.example similarity index 100% rename from sample-apps/goosify/.dev.vars.example rename to examples/goosify/.dev.vars.example diff --git a/sample-apps/goosify/.gitignore b/examples/goosify/.gitignore similarity index 100% rename from sample-apps/goosify/.gitignore rename to examples/goosify/.gitignore diff --git a/sample-apps/goosify/.prod.vars.example b/examples/goosify/.prod.vars.example similarity index 100% rename from sample-apps/goosify/.prod.vars.example rename to examples/goosify/.prod.vars.example diff --git a/sample-apps/goosify/README.md b/examples/goosify/README.md similarity index 100% rename from sample-apps/goosify/README.md rename to examples/goosify/README.md diff --git a/sample-apps/goosify/db-touch.sql b/examples/goosify/db-touch.sql similarity index 100% rename from sample-apps/goosify/db-touch.sql rename to examples/goosify/db-touch.sql diff --git a/sample-apps/goosify/drizzle.config.ts b/examples/goosify/drizzle.config.ts similarity index 100% rename from sample-apps/goosify/drizzle.config.ts rename to examples/goosify/drizzle.config.ts diff --git a/sample-apps/goosify/drizzle/migrations/0000_new_human_robot.sql b/examples/goosify/drizzle/migrations/0000_new_human_robot.sql similarity index 100% rename from sample-apps/goosify/drizzle/migrations/0000_new_human_robot.sql rename to examples/goosify/drizzle/migrations/0000_new_human_robot.sql diff --git a/sample-apps/goosify/drizzle/migrations/0001_omniscient_silver_fox.sql b/examples/goosify/drizzle/migrations/0001_omniscient_silver_fox.sql similarity index 100% rename from sample-apps/goosify/drizzle/migrations/0001_omniscient_silver_fox.sql rename to examples/goosify/drizzle/migrations/0001_omniscient_silver_fox.sql diff --git a/sample-apps/goosify/drizzle/migrations/meta/0000_snapshot.json b/examples/goosify/drizzle/migrations/meta/0000_snapshot.json similarity index 100% rename from sample-apps/goosify/drizzle/migrations/meta/0000_snapshot.json rename to examples/goosify/drizzle/migrations/meta/0000_snapshot.json diff --git a/sample-apps/goosify/drizzle/migrations/meta/0001_snapshot.json b/examples/goosify/drizzle/migrations/meta/0001_snapshot.json similarity index 100% rename from sample-apps/goosify/drizzle/migrations/meta/0001_snapshot.json rename to examples/goosify/drizzle/migrations/meta/0001_snapshot.json diff --git a/sample-apps/goosify/drizzle/migrations/meta/_journal.json b/examples/goosify/drizzle/migrations/meta/_journal.json similarity index 100% rename from sample-apps/goosify/drizzle/migrations/meta/_journal.json rename to examples/goosify/drizzle/migrations/meta/_journal.json diff --git a/sample-apps/goosify/package.json b/examples/goosify/package.json similarity index 100% rename from sample-apps/goosify/package.json rename to examples/goosify/package.json diff --git a/sample-apps/goosify/src/db/schema.ts b/examples/goosify/src/db/schema.ts similarity index 100% rename from sample-apps/goosify/src/db/schema.ts rename to examples/goosify/src/db/schema.ts diff --git a/sample-apps/goosify/src/index.ts b/examples/goosify/src/index.ts similarity index 100% rename from sample-apps/goosify/src/index.ts rename to examples/goosify/src/index.ts diff --git a/sample-apps/goosify/tsconfig.json b/examples/goosify/tsconfig.json similarity index 100% rename from sample-apps/goosify/tsconfig.json rename to examples/goosify/tsconfig.json diff --git a/sample-apps/goosify/wrangler.toml b/examples/goosify/wrangler.toml similarity index 100% rename from sample-apps/goosify/wrangler.toml rename to examples/goosify/wrangler.toml diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a6f8cf8e..c230ec2a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,71 +115,7 @@ importers: specifier: ^1.6.0 version: 1.6.0(@types/node@20.14.15) - packages/client-library-otel: - dependencies: - '@opentelemetry/api': - specifier: ~1.9.0 - version: 1.9.0 - '@opentelemetry/exporter-trace-otlp-http': - specifier: ^0.52.1 - version: 0.52.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-proto': - specifier: ^0.52.1 - version: 0.52.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': - specifier: ^1.25.1 - version: 1.25.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': - specifier: ^1.25.1 - version: 1.25.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': - specifier: ^1.25.1 - version: 1.25.1 - '@types/shimmer': - specifier: ^1.0.5 - version: 1.2.0 - shimmer: - specifier: ^1.2.1 - version: 1.2.1 - devDependencies: - '@biomejs/biome': - specifier: ^1.7.3 - version: 1.8.3 - '@cloudflare/workers-types': - specifier: ^4.20240403.0 - version: 4.20240806.0 - '@swc/cli': - specifier: ^0.4.0 - version: 0.4.0(@swc/core@1.7.10)(chokidar@3.6.0) - '@swc/core': - specifier: ^1.5.22 - version: 1.7.10 - '@swc/plugin-transform-imports': - specifier: ^2.0.4 - version: 2.0.11 - hono: - specifier: ^4.3.9 - version: 4.5.5 - nodemon: - specifier: ^3.1.4 - version: 3.1.4 - rimraf: - specifier: ^6.0.1 - version: 6.0.1 - tsc-alias: - specifier: ^1.8.10 - version: 1.8.10 - typescript: - specifier: ^5.4.5 - version: 5.5.4 - - packages/types: - devDependencies: - zod: - specifier: ^3.23.8 - version: 3.23.8 - - sample-apps/ai-request-generation: + examples/ai-request-generation: dependencies: '@fiberplane/hono-otel': specifier: workspace:* @@ -198,7 +134,7 @@ importers: specifier: ^3.72.3 version: 3.72.3(@cloudflare/workers-types@4.20240821.1) - sample-apps/goose-quotes: + examples/goose-quotes: dependencies: '@fiberplane/hono-otel': specifier: workspace:* @@ -229,7 +165,7 @@ importers: specifier: ^3.72.3 version: 3.72.3(@cloudflare/workers-types@4.20240821.1) - sample-apps/goosify: + examples/goosify: dependencies: '@fiberplane/hono-otel': specifier: workspace:* @@ -248,6 +184,70 @@ importers: specifier: ^3.72.3 version: 3.72.3(@cloudflare/workers-types@4.20240821.1) + packages/client-library-otel: + dependencies: + '@opentelemetry/api': + specifier: ~1.9.0 + version: 1.9.0 + '@opentelemetry/exporter-trace-otlp-http': + specifier: ^0.52.1 + version: 0.52.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-proto': + specifier: ^0.52.1 + version: 0.52.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': + specifier: ^1.25.1 + version: 1.25.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': + specifier: ^1.25.1 + version: 1.25.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': + specifier: ^1.25.1 + version: 1.25.1 + '@types/shimmer': + specifier: ^1.0.5 + version: 1.2.0 + shimmer: + specifier: ^1.2.1 + version: 1.2.1 + devDependencies: + '@biomejs/biome': + specifier: ^1.7.3 + version: 1.8.3 + '@cloudflare/workers-types': + specifier: ^4.20240403.0 + version: 4.20240806.0 + '@swc/cli': + specifier: ^0.4.0 + version: 0.4.0(@swc/core@1.7.10)(chokidar@3.6.0) + '@swc/core': + specifier: ^1.5.22 + version: 1.7.10 + '@swc/plugin-transform-imports': + specifier: ^2.0.4 + version: 2.0.11 + hono: + specifier: ^4.3.9 + version: 4.5.5 + nodemon: + specifier: ^3.1.4 + version: 3.1.4 + rimraf: + specifier: ^6.0.1 + version: 6.0.1 + tsc-alias: + specifier: ^1.8.10 + version: 1.8.10 + typescript: + specifier: ^5.4.5 + version: 5.5.4 + + packages/types: + devDependencies: + zod: + specifier: ^3.23.8 + version: 3.23.8 + studio: dependencies: '@codemirror/lang-javascript': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 04e7f41f6..49b12579c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,6 @@ packages: - "studio" - "api" - "packages/*" - - "sample-apps/*" + - "examples/*" - "webhonc" - "www" From 01474b122b3f85f3ffeb68a030603ed395ae309d Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 28 Aug 2024 11:44:41 +0200 Subject: [PATCH 23/25] Update ai request generation example --- .../ai-request-generation/src/db/schema.ts | 10 --- examples/ai-request-generation/src/index.ts | 85 +++++++++++++------ 2 files changed, 61 insertions(+), 34 deletions(-) delete mode 100644 examples/ai-request-generation/src/db/schema.ts diff --git a/examples/ai-request-generation/src/db/schema.ts b/examples/ai-request-generation/src/db/schema.ts deleted file mode 100644 index 4a9a5e34a..000000000 --- a/examples/ai-request-generation/src/db/schema.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { pgTable, serial, text, jsonb, timestamp } from 'drizzle-orm/pg-core'; - -export const users = pgTable('users', { - id: serial('id').primaryKey(), - name: text('name'), - email: text('email'), - settings: jsonb('settings'), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), -}); \ No newline at end of file diff --git a/examples/ai-request-generation/src/index.ts b/examples/ai-request-generation/src/index.ts index 0d8b82252..b315d4c73 100644 --- a/examples/ai-request-generation/src/index.ts +++ b/examples/ai-request-generation/src/index.ts @@ -1,7 +1,7 @@ -import { Hono } from 'hono'; -import { instrument } from '@fiberplane/hono-otel'; -import { makeRequestToolHermes } from './tools'; -import { getSystemPrompt } from './prompts'; +import { instrument } from "@fiberplane/hono-otel"; +import { Hono } from "hono"; +import { getSystemPrompt } from "./prompts"; +import { makeRequestToolHermes } from "./tools"; type Bindings = { DATABASE_URL: string; @@ -13,26 +13,35 @@ type Bindings = { AI: Ai; }; -const app = new Hono<{ Bindings: Bindings }>() +const app = new Hono<{ Bindings: Bindings }>(); -app.post('/', async (c) => { - const strTemperature = c.req.query('temperature') ?? "0.12"; - const temperature = Number.parseFloat(strTemperature) ?? 0.12; +app.post("/", async (c) => { + const temperature = parseTemperature(c.req.query("temperature"), 0.12); const body = await c.req.json(); - const inferenceResult = await runInference(c.env.AI, body.prompt, temperature) + const inferenceResult = await runInference( + c.env.AI, + body.prompt, + temperature, + ); // We are not using streaming outputs, but just in case, handle the stream here if (inferenceResult instanceof ReadableStream) { - return c.json({ - message: "Unexpected inference result (stream)", - }, 500) + return c.json( + { + message: "Unexpected inference result (stream)", + }, + 500, + ); } // We are theoretically enforcing a tool call, so this should not happen if (inferenceResult.response != null) { - return c.json({ - message: "Unexpected inference result (text)", - }, 500) + return c.json( + { + message: "Unexpected inference result (text)", + }, + 500, + ); } // Parse the tool call @@ -41,19 +50,26 @@ app.post('/', async (c) => { // TODO - Validate the request descriptor against the JSON Schema from the tool definition if (!isObjectGuard(requestDescriptor)) { - return c.json({ - message: "Invalid request descriptor" - }, 500) + return c.json( + { + message: "Invalid request descriptor", + }, + 500, + ); } console.log("requestDescriptor", JSON.stringify(requestDescriptor, null, 2)); - return c.json(requestDescriptor) -}) + return c.json(requestDescriptor); +}); export default instrument(app); -export async function runInference(client: Ai, userPrompt: string, temperature: number) { +export async function runInference( + client: Ai, + userPrompt: string, + temperature: number, +) { const result = await client.run( // @ts-ignore - This model exists in the Worker types as far as I can tell // I don't know why it's causing a typescript error here :( @@ -61,7 +77,10 @@ export async function runInference(client: Ai, userPrompt: string, temperature: { tools: [makeRequestToolHermes], // Restrict to only using this "make request" tool - tool_choice: { type: "function", function: { name: makeRequestToolHermes.name } }, + tool_choice: { + type: "function", + function: { name: makeRequestToolHermes.name }, + }, messages: [ { @@ -80,10 +99,28 @@ export async function runInference(client: Ai, userPrompt: string, temperature: // NOTE - The request will fail if you don't put the prompt here prompt: userPrompt, - }) + }, + ); // HACK - Need to coerce this to a AiTextGenerationOutput return result as AiTextGenerationOutput; } -const isObjectGuard = (value: unknown): value is object => typeof value === 'object' && value !== null; +function parseTemperature( + strTemperature: string | undefined, + fallback: number, +): number { + if (!strTemperature) { + return fallback; + } + + const temperature = Number.parseFloat(strTemperature); + if (Number.isNaN(temperature)) { + return fallback; + } + + return temperature; +} + +const isObjectGuard = (value: unknown): value is object => + typeof value === "object" && value !== null; From ef4ff82d55971fc36bbe548f5338828d5f67c7bc Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 28 Aug 2024 11:50:17 +0200 Subject: [PATCH 24/25] Turn off cloudflare binding instrumentation by default, and add it back in for the example apps --- examples/ai-request-generation/src/index.ts | 8 +++++++- examples/goose-quotes/src/index.ts | 8 +++++++- examples/goosify/src/index.ts | 8 +++++++- packages/client-library-otel/src/instrumentation.ts | 3 ++- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/examples/ai-request-generation/src/index.ts b/examples/ai-request-generation/src/index.ts index b315d4c73..796784e5a 100644 --- a/examples/ai-request-generation/src/index.ts +++ b/examples/ai-request-generation/src/index.ts @@ -63,7 +63,13 @@ app.post("/", async (c) => { return c.json(requestDescriptor); }); -export default instrument(app); +export default instrument(app, { + monitor: { + fetch: true, + logging: true, + cfBindings: true, + }, +}); export async function runInference( client: Ai, diff --git a/examples/goose-quotes/src/index.ts b/examples/goose-quotes/src/index.ts index 8dbb8ae30..d28a87cd7 100644 --- a/examples/goose-quotes/src/index.ts +++ b/examples/goose-quotes/src/index.ts @@ -494,7 +494,13 @@ app.get( }), ); -export default instrument(app); +export default instrument(app, { + monitor: { + fetch: true, + logging: true, + cfBindings: true, + }, +}); function trimPrompt(prompt: string) { return prompt diff --git a/examples/goosify/src/index.ts b/examples/goosify/src/index.ts index 409428868..eb15a7c5a 100644 --- a/examples/goosify/src/index.ts +++ b/examples/goosify/src/index.ts @@ -93,7 +93,13 @@ app.get("/api/cyberpunk-goose", async (c) => { return c.body(cyberpunkGooseImage); }); -export default instrument(app); +export default instrument(app, { + monitor: { + fetch: true, + logging: true, + cfBindings: true, + }, +}); function parseAcceptLanguage(acceptLanguage: string) { // Simple parser to get the most preferred language diff --git a/packages/client-library-otel/src/instrumentation.ts b/packages/client-library-otel/src/instrumentation.ts index 59cb2ee75..0cc61442d 100644 --- a/packages/client-library-otel/src/instrumentation.ts +++ b/packages/client-library-otel/src/instrumentation.ts @@ -60,7 +60,8 @@ const defaultConfig = { monitor: { fetch: true, logging: true, - cfBindings: true, + // NOTE - We don't proxy Cloudflare bindings by default yet because it's still experimental, and we don't have fancy UI for it yet in the Studio + cfBindings: false, }, }; From e7fac80fe9f443689e6e5db9ee644c45c1b688e3 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 28 Aug 2024 11:54:01 +0200 Subject: [PATCH 25/25] Update dev vars example for goosify --- examples/goosify/.dev.vars.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/goosify/.dev.vars.example b/examples/goosify/.dev.vars.example index 168ca871f..56568de93 100644 --- a/examples/goosify/.dev.vars.example +++ b/examples/goosify/.dev.vars.example @@ -1 +1 @@ -FPX_ENDPOINT=... \ No newline at end of file +FPX_ENDPOINT=http://localhost:8788/v1/traces \ No newline at end of file