@teamkeel/functions-runtime 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tracing.js ADDED
@@ -0,0 +1,135 @@
1
+ const opentelemetry = require("@opentelemetry/api");
2
+ const { BatchSpanProcessor } = require("@opentelemetry/sdk-trace-base");
3
+ const {
4
+ OTLPTraceExporter,
5
+ } = require("@opentelemetry/exporter-trace-otlp-proto");
6
+ const { NodeTracerProvider } = require("@opentelemetry/sdk-trace-node");
7
+ const { envDetectorSync } = require("@opentelemetry/resources");
8
+
9
+ function withSpan(name, fn) {
10
+ return getTracer().startActiveSpan(name, async (span) => {
11
+ try {
12
+ // await the thing (this means we can use try/catch)
13
+ return await fn(span);
14
+ } catch (err) {
15
+ // record any errors
16
+ span.recordException(err);
17
+ span.setStatus({
18
+ code: opentelemetry.SpanStatusCode.ERROR,
19
+ message: err.message,
20
+ });
21
+ // re-throw the error
22
+ throw err;
23
+ } finally {
24
+ // make sure the span is ended
25
+ span.end();
26
+ }
27
+ });
28
+ }
29
+
30
+ function patchFetch() {
31
+ if (!globalThis.fetch.patched) {
32
+ const originalFetch = globalThis.fetch;
33
+
34
+ globalThis.fetch = async (...args) => {
35
+ return withSpan("fetch", async (span) => {
36
+ const url = new URL(
37
+ args[0] instanceof Request ? args[0].url : String(args[0])
38
+ );
39
+ span.setAttribute("http.url", url.toString());
40
+ const scheme = url.protocol.replace(":", "");
41
+ span.setAttribute("http.scheme", scheme);
42
+
43
+ const options = args[0] instanceof Request ? args[0] : args[1] || {};
44
+ const method = (options.method || "GET").toUpperCase();
45
+ span.setAttribute("http.method", method);
46
+
47
+ const res = await originalFetch(...args);
48
+ span.setAttribute("http.status", res.status);
49
+ span.setAttribute("http.status_text", res.statusText);
50
+ return res;
51
+ });
52
+ };
53
+ globalThis.fetch.patched = true;
54
+ }
55
+ }
56
+
57
+ function patchConsoleLog() {
58
+ if (!console.log.patched) {
59
+ const originalConsoleLog = console.log;
60
+
61
+ console.log = (...args) => {
62
+ const span = opentelemetry.trace.getActiveSpan();
63
+ if (span) {
64
+ const output = args
65
+ .map((arg) => {
66
+ if (arg instanceof Error) {
67
+ return arg.stack;
68
+ }
69
+ if (typeof arg === "object") {
70
+ try {
71
+ return JSON.stringify(arg, getCircularReplacer());
72
+ } catch (error) {
73
+ return "[Object with circular references]";
74
+ }
75
+ }
76
+ if (typeof arg === "function") {
77
+ return arg() || arg.name || arg.toString();
78
+ }
79
+ return String(arg);
80
+ })
81
+ .join(" ");
82
+
83
+ span.addEvent(output);
84
+ }
85
+ originalConsoleLog(...args);
86
+ };
87
+
88
+ console.log.patched = true;
89
+ }
90
+ }
91
+
92
+ // Utility to handle circular references in objects
93
+ function getCircularReplacer() {
94
+ const seen = new WeakSet();
95
+ return (key, value) => {
96
+ if (typeof value === "object" && value !== null) {
97
+ if (seen.has(value)) {
98
+ return "[Circular]";
99
+ }
100
+ seen.add(value);
101
+ }
102
+ return value;
103
+ };
104
+ }
105
+
106
+ function init() {
107
+ if (process.env.KEEL_TRACING_ENABLED == "true") {
108
+ const provider = new NodeTracerProvider({
109
+ resource: envDetectorSync.detect(),
110
+ });
111
+ const exporter = new OTLPTraceExporter();
112
+ const processor = new BatchSpanProcessor(exporter);
113
+
114
+ provider.addSpanProcessor(processor);
115
+ provider.register();
116
+ }
117
+
118
+ patchFetch();
119
+ patchConsoleLog();
120
+ }
121
+
122
+ function getTracer() {
123
+ return opentelemetry.trace.getTracer("functions");
124
+ }
125
+
126
+ function spanNameForModelAPI(modelName, action) {
127
+ return `Database ${modelName}.${action}`;
128
+ }
129
+
130
+ module.exports = {
131
+ getTracer,
132
+ withSpan,
133
+ init,
134
+ spanNameForModelAPI,
135
+ };
@@ -0,0 +1,119 @@
1
+ import { expect, test, beforeEach } from "vitest";
2
+ import tracing from "./tracing";
3
+ import { NodeTracerProvider, Span } from "@opentelemetry/sdk-trace-node";
4
+
5
+ let spanEvents = [];
6
+ const provider = new NodeTracerProvider({});
7
+ provider.addSpanProcessor({
8
+ forceFlush() {
9
+ return Promise.resolve();
10
+ },
11
+ onStart(span, parentContext) {
12
+ spanEvents.push({ event: "onStart", span, parentContext });
13
+ },
14
+ onEnd(span) {
15
+ spanEvents.push({ event: "onEnd", span });
16
+ },
17
+ shutdown() {
18
+ return Promise.resolve();
19
+ },
20
+ });
21
+ provider.register();
22
+
23
+ beforeEach(() => {
24
+ tracing.init();
25
+ spanEvents = [];
26
+ });
27
+
28
+ test("withSpan span time", async () => {
29
+ const waitTimeMillis = 100;
30
+ await tracing.withSpan("name", async () => {
31
+ await new Promise((resolve) => setTimeout(resolve, waitTimeMillis));
32
+ });
33
+
34
+ expect(spanEvents.map((e) => e.event)).toEqual(["onStart", "onEnd"]);
35
+ const spanDuration = spanEvents.pop().span._duration.pop();
36
+ const waitTimeNanos = waitTimeMillis * 1000 * 1000;
37
+ expect(spanDuration).toBeGreaterThan(waitTimeNanos);
38
+ });
39
+
40
+ test("withSpan on error", async () => {
41
+ try {
42
+ await tracing.withSpan("name", async () => {
43
+ throw "err";
44
+ });
45
+ // previous line should have an error thrown
46
+ expect(true).toEqual(false);
47
+ } catch (e) {
48
+ expect(e).toEqual("err");
49
+ expect(spanEvents.map((e) => e.event)).toEqual(["onStart", "onEnd"]);
50
+ const lastSpanEvents = spanEvents.pop().span.events;
51
+ expect(lastSpanEvents).length(1);
52
+ expect(lastSpanEvents[0].name).toEqual("exception");
53
+ expect(lastSpanEvents[0].attributes).toEqual({
54
+ "exception.message": "err",
55
+ });
56
+ }
57
+ });
58
+
59
+ test("fetch - 200", async () => {
60
+ const res = await fetch("http://example.com");
61
+ expect(res.status).toEqual(200);
62
+
63
+ expect(spanEvents.map((e) => e.event)).toEqual(["onStart", "onEnd"]);
64
+ expect(spanEvents.pop().span.attributes).toEqual({
65
+ "http.url": "http://example.com/",
66
+ "http.scheme": "http",
67
+ "http.method": "GET",
68
+ "http.status": 200,
69
+ "http.status_text": "OK",
70
+ });
71
+ });
72
+
73
+ test("fetch - 404", async () => {
74
+ await fetch("http://example.com/movies.json");
75
+
76
+ expect(spanEvents.map((e) => e.event)).toEqual(["onStart", "onEnd"]);
77
+ expect(spanEvents.pop().span.attributes).toEqual({
78
+ "http.url": "http://example.com/movies.json",
79
+ "http.scheme": "http",
80
+ "http.method": "GET",
81
+ "http.status": 404,
82
+ "http.status_text": "Not Found",
83
+ });
84
+ });
85
+
86
+ test("fetch - invalid URL", async () => {
87
+ try {
88
+ await fetch({});
89
+ } catch (err) {
90
+ expect(err.message).toEqual("Invalid URL");
91
+ }
92
+
93
+ expect(spanEvents.map((e) => e.event)).toEqual(["onStart", "onEnd"]);
94
+
95
+ const span = spanEvents.pop().span;
96
+ expect(spanEvents.pop().span.attributes).toEqual({});
97
+ expect(span.events[0].name).toEqual("exception");
98
+ expect.assertions(4);
99
+ });
100
+
101
+ test("fetch - ENOTFOUND", async () => {
102
+ try {
103
+ await fetch("http://qpwoeuthnvksnvnsanrurvnc.com");
104
+ } catch (err) {
105
+ expect(err.message).toEqual("fetch failed");
106
+ expect(err.cause.code).toEqual("ENOTFOUND");
107
+ }
108
+
109
+ expect(spanEvents.map((e) => e.event)).toEqual(["onStart", "onEnd"]);
110
+
111
+ const span = spanEvents.pop().span;
112
+ expect(span.attributes).toEqual({
113
+ "http.method": "GET",
114
+ "http.scheme": "http",
115
+ "http.url": "http://qpwoeuthnvksnvnsanrurvnc.com/",
116
+ });
117
+ expect(span.events[0].name).toEqual("exception");
118
+ expect.assertions(5);
119
+ });
@@ -0,0 +1,74 @@
1
+ const { withDatabase } = require("./database");
2
+ const {
3
+ withPermissions,
4
+ PERMISSION_STATE,
5
+ PermissionError,
6
+ checkBuiltInPermissions,
7
+ } = require("./permissions");
8
+ const { PROTO_ACTION_TYPES } = require("./consts");
9
+
10
+ // tryExecuteFunction will create a new database transaction around a function call
11
+ // and handle any permissions checks. If a permission check fails, then an Error will be thrown and the catch block will be hit.
12
+ function tryExecuteFunction(
13
+ { db, permitted, permissionFns, actionType, request, ctx },
14
+ cb
15
+ ) {
16
+ return withPermissions(permitted, async ({ getPermissionState }) => {
17
+ return withDatabase(db, actionType, async ({ transaction }) => {
18
+ const fnResult = await cb();
19
+ // api.permissions maintains an internal state of whether the current operation has been *explicitly* permitted/denied by the user in the course of their custom function, or if execution has already been permitted by a role based permission (evaluated in the main runtime).
20
+ // we need to check that the final state is permitted or unpermitted. if it's not, then it means that the user has taken no explicit action to permit/deny
21
+ // and therefore we default to checking the permissions defined in the schema automatically.
22
+ switch (getPermissionState()) {
23
+ case PERMISSION_STATE.PERMITTED:
24
+ return fnResult;
25
+ case PERMISSION_STATE.UNPERMITTED:
26
+ throw new PermissionError(
27
+ `Not permitted to access ${request.method}`
28
+ );
29
+ default:
30
+ // unknown state, proceed with checking against the built in permissions in the schema
31
+ const relevantPermissions = permissionFns[request.method];
32
+
33
+ const peakInsideTransaction =
34
+ actionType === PROTO_ACTION_TYPES.CREATE;
35
+
36
+ let rowsForPermissions = [];
37
+ if (fnResult != null) {
38
+ switch (actionType) {
39
+ case PROTO_ACTION_TYPES.LIST:
40
+ rowsForPermissions = fnResult;
41
+ break;
42
+ case PROTO_ACTION_TYPES.DELETE:
43
+ rowsForPermissions = [{ id: fnResult }];
44
+ break;
45
+ case (PROTO_ACTION_TYPES.GET, PROTO_ACTION_TYPES.CREATE):
46
+ rowsForPermissions = [fnResult];
47
+ break;
48
+ default:
49
+ rowsForPermissions = [fnResult];
50
+ break;
51
+ }
52
+ }
53
+
54
+ // check will throw a PermissionError if a permission rule is invalid
55
+ await checkBuiltInPermissions({
56
+ rows: rowsForPermissions,
57
+ permissionFns: relevantPermissions,
58
+ // it is important that we pass db here as db represents the connection to the database
59
+ // *outside* of the current transaction. Given that any changes inside of a transaction
60
+ // are opaque to the outside, we can utilize this when running permission rules and then deciding to
61
+ // rollback any changes if they do not pass. However, for creates we need to be able to 'peak' inside the transaction to read the created record, as this won't exist outside of the transaction.
62
+ db: peakInsideTransaction ? transaction : db,
63
+ ctx,
64
+ functionName: request.method,
65
+ });
66
+
67
+ // If the built in permission check above doesn't throw, then it means that the request is permitted and we can continue returning the return value from the custom function out of the transaction
68
+ return fnResult;
69
+ }
70
+ });
71
+ });
72
+ }
73
+
74
+ module.exports.tryExecuteFunction = tryExecuteFunction;
package/vite.config.js ADDED
@@ -0,0 +1,7 @@
1
+ import { defineConfig, loadEnv } from "vite";
2
+
3
+ export default ({ mode }) => {
4
+ process.env = { ...process.env, ...loadEnv(mode, process.cwd(), "") };
5
+
6
+ return defineConfig({});
7
+ };