@knime/hub-features 1.26.2 → 1.27.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @knime/hub-features
2
2
 
3
+ ## 1.27.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3a49573: Rework API for analytic events and the underlying schema
8
+
3
9
  ## 1.26.2
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knime/hub-features",
3
- "version": "1.26.2",
3
+ "version": "1.27.0",
4
4
  "description": "Vue components & composables for shared hub features",
5
5
  "repository": {
6
6
  "type": "git",
@@ -62,6 +62,8 @@
62
62
  "scripts": {
63
63
  "type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
64
64
  "test:unit": "TZ='Europe/Berlin' vitest",
65
- "generate-analytics-events": "json2ts ./src/analytics/schema/schema.json > ./src/analytics/schema/schema.d.ts"
65
+ "generate-analytics-events": "pnpm generate-event-types && pnpm generate-event-functions",
66
+ "generate-event-types": "json2ts ./src/analytics/schema/schema.json > ./src/analytics/schema/schema.d.ts",
67
+ "generate-event-functions": "node ./src/analytics/schema/code-generator.js"
66
68
  }
67
69
  }
@@ -2,86 +2,32 @@
2
2
 
3
3
  ## Preface
4
4
 
5
- This package provides helpers related to analytics events. It provides 2 main behaviors:
6
-
7
- - Provides a builder function that creates and normalizes event payloads
8
- - Provides a sender function that forwards a given event payload to a third party API.
9
- The implementation of the third-party API is up to the consumer as long as it satisfies the adapter contract
10
-
11
- The main reason why these behaviors are split are for increased flexibility and to also enable this feature being used on a setup where the creation of the event is separated to the sending of the event. Like for example, on host/guest application combination where there's an iframe communication layer between both apps.
5
+ This package provides functions that can produce analytics events.
12
6
 
13
7
  ## Usage examples
14
8
 
15
- ### Building event payloads
16
-
17
- As stated above, some applications might only care (or have the correct setup) to construct events based on different code paths and user interactions.
9
+ This package makes no assumption of how the events are sent. Therefore, one must supply a "sender" as
10
+ part of the initialization.
18
11
 
19
12
  ```ts
20
13
  import { analyticsEvents } from "@knime/hub-features/analytics";
21
14
 
22
- // Create a small factory bound to your context
23
- const { newEvent } = analyticsEvents.eventBuilder({ jobId: "job-123" });
24
-
25
- // Build an event. Event ids are strongly-typed, combining their special format
26
- // and validating payloads, if required
27
- const event = newEvent({ id: "kai_prompted::kaiqa_button_prompt" });
28
-
29
- // once you have this event object, the application can decide what to do with it.
30
- // Whether that is sending it through an iframe as a message or some other event bus. OR sending it directly if it's so desired (see Sending events section)
31
- ```
32
-
33
- ### Keeping types in-sync
34
-
35
- Due to the strict-type setup and the shape of the API of this package, reusing the type signature
36
- of the function that creates the events can be tricky. However, this is helpful, for example, if you want to
37
- wrap the create function with some other behavior of your own but keep the strong-type guarantees.
38
- Below you can see an example for this use-case:
39
-
40
- ```ts
41
- import {
42
- analyticsEvents,
43
- type CreateEventFn,
44
- } from "@knime/hub-features/analytics";
45
-
46
- const myCustomFunction: CreateEventFn = (...args) => {
47
- const { newEvent } = analyticsEvents.eventBuilder(TheNeededContext);
48
-
49
- // do something before
50
-
51
- const event = newEvent(...args);
52
-
53
- // do something with event
15
+ // Create a sender, which can be pretty much any object that provides a `send` function
16
+ const mySender = {
17
+ send: ({ event }) => {
18
+ // do something with event (e.g send over WS, dispatch to an iframe's parent, etc)
19
+ },
54
20
  };
55
- ```
56
-
57
- In this case, the type signature of `myCustomFunction` will match the one of `newEvent`, just like
58
- in the previous example.
59
-
60
- ### Sending events
61
-
62
- As stated in the preface, to send events, you need to provide an adapter
63
-
64
- ```ts
65
- import {
66
- analyticsEvents,
67
- type AnalyticsAdapter,
68
- } from "@knime/hub-features/analytics";
69
-
70
- const myAdapter: AnalyticsAdapter = {
71
- sendEvent({ event, metadata, idParser }) {
72
- console.log("Parsed id", idParser(event.id));
73
- console.log("Metadata", metadata);
74
- console.log("Event", event)
75
- }
76
- }
77
-
78
- // create the sender
79
- const sender = analyticsEvents.eventSender(myAdapter);
80
-
81
- // some event
82
- const { newEvent } = analyticsEvents.eventBuilder(TheNeededContext);
83
- const event = newEvent(...);
84
21
 
85
- // then...
86
- sender.send(event);
22
+ // Initialize the tracker
23
+ type SomeContext = { myContextField: string };
24
+ const eventTracker = analyticsEvents.init<SomeContext>({
25
+ context: { myContextField: "foo-123" },
26
+ sender: mySender,
27
+ });
28
+
29
+ // Track an event. `someEvent` here is a placeholder name. In reality, the API will provide named functions that
30
+ // strongly-typed and may or may not require payload depending on the event's definition. Under the hood this will
31
+ // use the configured sender to forward your event
32
+ eventTracker.someEvent();
87
33
  ```
@@ -1,76 +1,90 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import * as functions from "./schema/event-functions";
1
3
  import type {
2
- AnalyticsAdapter,
3
- AnalyticsPayload,
4
- Context,
5
- CreateEventFn,
4
+ AnalyticsEvent,
5
+ AnalyticsEventSender,
6
+ DefaultContext,
6
7
  Metadata,
7
8
  } from "./types";
8
- import { eventID } from "./utils/eventIds";
9
- import { toSnakeCaseDeep } from "./utils/toSnakeCaseDeep";
10
9
 
11
10
  const METADATA: Metadata = Object.freeze({
12
- version: "v1.0",
11
+ version: "v2.0",
13
12
  });
14
13
 
14
+ /**
15
+ * Unwraps the inner function imported from the generated functions.
16
+ * With this supply the context here and just expose the inner function
17
+ * but also ensure we keep the same names and type constraints
18
+ */
19
+ type UnwrapFunction<T> = T extends (ctx: DefaultContext) => infer R
20
+ ? R extends (...args: infer P) => infer Q
21
+ ? (...args: P) => Q
22
+ : never
23
+ : never;
24
+
25
+ type FunctionMap = typeof functions;
26
+ type Names = keyof FunctionMap;
27
+ type BuilderFunctions = {
28
+ [K in Names]: UnwrapFunction<(typeof functions)[K]>;
29
+ };
30
+
31
+ type SenderFunctions = {
32
+ [K in Names]: (...args: Parameters<BuilderFunctions[K]>) => void;
33
+ };
34
+
35
+ /**
36
+ * This function creates an event builder, which lets you construct correct and strongly-typed
37
+ * event payloads according to the current schema used by this package
38
+ * @param context
39
+ * @returns
40
+ */
41
+ function eventBuilder<T extends DefaultContext>(context: T) {
42
+ const unwrapped = Object.fromEntries(
43
+ Object.entries(functions).map(([name, fn]) => [name, fn(context)]),
44
+ ) as BuilderFunctions;
45
+
46
+ return { ...unwrapped };
47
+ }
48
+
15
49
  export const analyticsEvents = Object.freeze({
16
50
  METADATA,
17
51
  /**
18
- * This function creates an event builder, which lets you construct correct and strongly-typed
19
- * event payloads according to the current schema used by this package
20
- * @param context
21
- * @returns
52
+ * Initialize the analytic event tracker
22
53
  */
23
- eventBuilder: (context: Context) => {
24
- const newEvent: CreateEventFn<AnalyticsPayload> = ({
25
- id: eventId,
26
- payload: eventData,
27
- }) => {
28
- // runtime check just in case - shouldn't be needed due to TS
29
- if (!eventID(eventId).isValid()) {
30
- throw new Error(`Implementation error: Invalid event id: ${eventId}`);
31
- }
54
+ init: <T extends DefaultContext>(params: {
55
+ /**
56
+ * Context for the initialized tracker. This will remain unchanged throughout the lifecycle
57
+ * of this tracker
58
+ */
59
+ context: T;
60
+ /**
61
+ * Sender interface; will be used to determine how to handle each event when its
62
+ * corresponding track function is invoked
63
+ */
64
+ sender: AnalyticsEventSender;
65
+ }) => {
66
+ const builder = eventBuilder(params.context);
32
67
 
33
- const uniqueEventId = crypto.randomUUID();
68
+ type AnyFn = (...args: any[]) => any;
34
69
 
35
- const data = (() => {
36
- if (!eventData) {
37
- return undefined;
38
- }
70
+ const wrapper: SenderFunctions = {} as SenderFunctions;
39
71
 
40
- return toSnakeCaseDeep((eventData as unknown) ?? {});
41
- })();
72
+ // keep all the same functions of the builder but wrap around them for extra behavior
73
+ Object.keys(builder).forEach((fnName) => {
74
+ const key = fnName as keyof SenderFunctions;
75
+ wrapper[key] = (...args: any[]) => {
76
+ // TS issue:
77
+ // ```A spread argument must either have a tuple type or be passed to a rest parameter.```
78
+ // This happens because when calling the methods generically, TS tries to join all function signatures
79
+ // into a union of tuples that contains all possible argument combinations for all of the functions.
80
+ // This leads to an unresolvable type error
81
+ // More details on the issue, see: https://github.com/microsoft/TypeScript/issues/49700
82
+ const event: AnalyticsEvent = (builder[key] as AnyFn)(...args);
42
83
 
43
- const event = {
44
- id: `editor_${eventId}`,
45
- data: {
46
- ...data,
47
- // eslint-disable-next-line camelcase
48
- job_id: context.jobId,
49
- // eslint-disable-next-line camelcase
50
- event_id: uniqueEventId,
51
- timestamp: new Date().toISOString(),
52
- },
84
+ params.sender.send({ event, metadata: METADATA });
53
85
  };
86
+ });
54
87
 
55
- return event;
56
- };
57
-
58
- return { newEvent };
59
- },
60
- /**
61
- * This function creates an event sender which will use an internal implementation of an adapter
62
- * to forward the event's data to an external third-party provider
63
- * @returns
64
- */
65
- eventSender: (adapter: AnalyticsAdapter) => {
66
- const send = (event: AnalyticsPayload) => {
67
- adapter.sendEvent({
68
- event,
69
- idParser: eventID(event.id).parse,
70
- metadata: METADATA,
71
- });
72
- };
73
-
74
- return { send };
88
+ return wrapper;
75
89
  },
76
90
  });
@@ -1,2 +1,2 @@
1
1
  export * from "./analytics";
2
- export type { CreateEventFn, AnalyticsAdapter } from "./types";
2
+ export type { AnalyticsEvent, AnalyticsEventSender } from "./types";
@@ -0,0 +1,147 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const schemaPath = path.join(__dirname, "schema.json");
7
+ const outputPath = path.join(__dirname, "event-functions.ts");
8
+
9
+ const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
10
+ const events = schema.properties.events.properties;
11
+
12
+ const TYPE_HELPERS = {
13
+ analyticsEvent: "AnalyticsEvent",
14
+ defaultContext: "DefaultContext",
15
+ fnParams: "EventFunctionArgs",
16
+ generatedStaticFields: "GeneratedStaticFields",
17
+ schemaStaticFields: "SchemaStaticFields",
18
+ };
19
+
20
+ const HEADER_LINES = [
21
+ "/* eslint-disable max-lines */",
22
+ "/* eslint-disable camelcase */",
23
+ "/* eslint-disable @typescript-eslint/no-explicit-any */",
24
+ "// AUTO-GENERATED FILE. DO NOT EDIT.",
25
+ `import type { ${Object.values(TYPE_HELPERS).join(", ")} } from "../types";`,
26
+ 'import { toSnakeCaseDeep } from "../utils/toSnakeCaseDeep";',
27
+ ];
28
+ const result = [];
29
+
30
+ /**
31
+ * Generic event definition, read from the schema.json.
32
+ * @typedef {{
33
+ * properties: {
34
+ * data: { $ref?: string };
35
+ * [key: string]: any;
36
+ * };
37
+ * $comment?: string;
38
+ * }} EventDefinition
39
+ */
40
+
41
+ /**
42
+ * Generate the JSDoc string for the generated event function, if it has a `$comment` field
43
+ * in the schema
44
+ * @param {EventDefinition} eventDef
45
+ * @returns {String}
46
+ */
47
+ const generateJSDocComment = (eventDef) => {
48
+ // eslint-disable-next-line no-useless-concat
49
+ const jsdoc = "/**\n" + ` * ${eventDef.$comment}\n` + " */";
50
+ return jsdoc;
51
+ };
52
+
53
+ /**
54
+ * Resolves the const value from a property, handling both direct `const` and
55
+ * `allOf`-based patterns like `{ allOf: [{ $ref: "..." }, { const: "value" }] }`
56
+ * @param {{ const?: any; allOf?: Array<{ const?: any }> }} prop
57
+ * @returns {any | undefined}
58
+ */
59
+ const resolveConst = (prop) => {
60
+ if (prop.const !== undefined) {
61
+ return prop.const;
62
+ }
63
+ if (Array.isArray(prop.allOf)) {
64
+ return prop.allOf.find((s) => s.const !== undefined)?.const;
65
+ }
66
+ return undefined;
67
+ };
68
+
69
+ /**
70
+ * Generates an event function for an event based on its name and definition
71
+ * @param {String} fnName
72
+ * @param {EventDefinition} eventDef
73
+ * @param {Boolean} isPayloadRequired whether the function requires a data payload
74
+ * @returns {String} function as a string to be added to the generated file
75
+ */
76
+ const generateEventFunction = (fnName, eventDef, isPayloadRequired = true) => {
77
+ const params = isPayloadRequired
78
+ ? `eventData: ${TYPE_HELPERS.fnParams}<"${fnName}">`
79
+ : "";
80
+
81
+ let fn =
82
+ // function header
83
+ `export const ${fnName} = <T extends ${TYPE_HELPERS.defaultContext}>(ctx: T) => (${params}): ${TYPE_HELPERS.analyticsEvent} => {\n` +
84
+ // open object for static data and add properties that are static but generated at runtime
85
+ " const staticData = {\n" +
86
+ " ...toSnakeCaseDeep(ctx),\n" +
87
+ " unique_event_id: crypto.randomUUID(),\n" +
88
+ " timestamp: new Date().toISOString(),\n";
89
+
90
+ for (const [key, prop] of Object.entries(eventDef.properties)) {
91
+ // add properties that are static and whose values come from the schema
92
+ const constValue = resolveConst(prop);
93
+ if (constValue !== undefined) {
94
+ fn += ` ${key}: "${constValue}",\n`;
95
+ } else if (prop.type === "object" && prop.properties) {
96
+ // nested object (e.g. action): emit only sub-properties that have const values
97
+ fn += ` ${key}: {\n`;
98
+ for (const [subKey, subProp] of Object.entries(prop.properties)) {
99
+ const subConstValue = resolveConst(subProp);
100
+ if (subConstValue !== undefined) {
101
+ fn += ` ${subKey}: "${subConstValue}",\n`;
102
+ }
103
+ }
104
+ fn += " },\n";
105
+ }
106
+ }
107
+
108
+ // close object for staticData
109
+ fn += ` } satisfies ${TYPE_HELPERS.schemaStaticFields} & ${TYPE_HELPERS.generatedStaticFields};\n\n`;
110
+
111
+ const payload = isPayloadRequired
112
+ ? "{ ...staticData, payload: { ...toSnakeCaseDeep(eventData as Record<string, any>) } }"
113
+ : "{ ...staticData }";
114
+
115
+ fn += ` const event = { id: staticData.event_name, data: ${payload} };\n\n`;
116
+ fn += " return event;\n";
117
+ fn += "};\n\n";
118
+
119
+ return fn;
120
+ };
121
+
122
+ /**
123
+ * Checks whether the given event definition has an empty payload or requires data
124
+ * @param {EventDefinition} eventDef
125
+ * @returns
126
+ */
127
+ const hasEmptyPayload = (eventDef) => {
128
+ return Boolean(eventDef.properties.payload?.$ref?.includes("EmptyPayload"));
129
+ };
130
+
131
+ for (const [fnName, eventDef] of Object.entries(events)) {
132
+ const isPayloadRequired = !hasEmptyPayload(eventDef);
133
+ let fn = generateEventFunction(fnName, eventDef, isPayloadRequired);
134
+
135
+ if (eventDef.$comment) {
136
+ const jsdoc = generateJSDocComment(eventDef);
137
+ fn = `${jsdoc}\n${fn}`;
138
+ }
139
+
140
+ result.push(fn);
141
+ }
142
+
143
+ const fileContent = `${HEADER_LINES.join("\n")}\n\n${result.join("")}`;
144
+
145
+ fs.writeFileSync(outputPath, fileContent, "utf8");
146
+ // eslint-disable-next-line no-console
147
+ console.log("Generated static data based on the Events JSON Schema.");