+![Main elements of the tracking code.](./analytics-layout.svg)
+
+
+The user code first calls the static `getTrackingProviders()` method which initialises the tracking providers.
+This returns an instance of the `TrackingAPI` , which can then subsequently be used to emit analytics events.
+
+User code (blueish box) only interacts with the classes/methods in green.
+
+## Usage
+
+Before the code can be used, it is necessary to once supply the api-keys of the respective providers:
+```nolive
+const initProps: InitProps = {
+ segmentKey: 'TODO-key', // TODO add your key here
+// segmentCdn: 'https://my.org/cdn', // Set up segment cdn (optional)
+// segmentIntegrations: { // Provide Segment integations (optional)
+// 'Segment.io': {
+// apiHost: 'my.api.host/api/v1',
+// protocol: 'https'
+// }
+ },
+
+ posthogKey: 'TODO-key',
+ umamiKey: 'TODO-key',
+ umamiHostUrl: 'http://localhost:3000', // TODO where is your JS provider?
+ something: 'test',
+ console: 'true' // Console provider
+};
+```
+
+Once this is done, you can get an instance of the `TrackingAPI` and then start sending events.
+One of the first events should be to identify the user in some way (this can be a UUID, which stays the same for the same user).
+
+
+
+```nolive
+const trackingAPI = getTrackingProviders(initProps);
+trackingAPI.identify('user-123'); // TODO get real user id
+// Track the page, that is currently visited. Best put into a react effect (see below)
+trackingAPI.trackPageView();
+// Track a single Event
+trackingAPI.trackSingleItem("MyEvent", { response: 'Good response' })
+```
+
+### Page hit tracking
+
+For page flow tracking, you can use a snippet like this.
+
+```nolive
+import React from 'react';
+import { useLocation } from 'react-router-dom';
+
+export const useTrackPageFlow = (): void => {
+ const { pathname } = useLocation();
+
+ // notify url change events
+ React.useEffect(() => {
+ trackingAPI.trackPageView();
+ }, [pathname]);
+};
+```
+
+### Single Event tracking
+
+To track single events (e.g. Button Press, Form Submission), you use the `trackSingleItem` method.
+
+```nolive
+trackingAPI.trackSingleItem(eventName, propertyDict)
+```
+
+The method takes two parameters:
+
+* eventName : name of the event. Should be unique throughout the application (or you need to differentiate different events with the same name by supplying an additional property).
+* propertyDict: a dict with key-value pairs that represent important properties of the event. The dict can be empty.
+
+When submitting forms, you only instrument the form itself. Don't instrument the button that calls the form.
+Two major cases need to be distinguished:
+
+* Form submitted
+* * Form submission action was successful
+* * Form submission action failed
+* Form was cancelled
+
+For form submission, you can use the
+```nolive
+trackingAPI.trackSingleItem(Event_Name, {
+ outcome: << submit , cancel >>,
+ success? : boolean,
+ error? : string,
+ , string/number/boolean } )
+```
+
+call. Outcome is obvious, for `outcome=submit`, `success` denotes if the action (in the backend) was successful or not.
+For the case of failure, `error` would yield the error message (try to strip out variable parts like the random part of a container-name).
+`` are some additional properties from the form, that should be sent to analytics.
+Take good judgement, what to send.
+Items like names provided by the user above should not be sent.
+Likewise for the content of a description field.
+On the other hand for Deployments, replica count or memory server size are items that could be sent and will help us to better understand the users.
+
+
+### Enable analytics gathering
+
+Only providers that have a matching key in the `InitProps` will be started and used.
+
+```nolive
+const initProps: InitProps = {
+ segmentKey: 'TODO-key', // TODO add your key here
+ posthogKey: 'TODO-key',
+ umamiKey: 'TODO-key',
+ umamiHostUrl: 'http://localhost:3000', // TODO where is your JS provider?
+ console: true
+```
+
+If you know upfront that you only want to use one of the providers, it is possible to modify
+`getTrackingProviders()` and omit the unneeded providers in the providers array.
+
+### package.json
+
+When using the providers you need to add some dependencies in package.json like:
+
+```nolive
+"dependencies": {
+ "@segment/analytics-next": "^1.76.0",
+ "posthog-js": "^1.194.4"
+```
+
+## Examples
+
+I have started the ChatBot and done the actions 1-5 in order:
+
+
+![Events done in the chatbot.](./Events.png)
+
+
+1. Select a Model
+2. Posted a question
+3. Got an answer from the model
+4. Clicked the thumbsUp button
+5. Closed the chatbot
+
+### Segment
+
+Segment shows the events in its Source debugger with the newest event at the top.
+Below the numbered events, you can also see the results of `identify` and `trackPageView`.
+
+
+![Events display in the Segment debugger.](./Events_segment.png)
+
+
+If you clicked on an event, you'd get to see the passed properties.
+
+**Note**: When using the Segment provider, you may also want to set the
+`segmentCdn` and `segmentIntegrations` initialization properties.
+
+### Umami
+
+Events are visible in Umami under Website -> Events:
+
+The list is pretty similar to Segment, just differently formatted.
+
+
+![Events in Umami.](./Events_umami.png)
+
+
+### PostHog
+
+PostHog shows the Events in the Activity section.
+
+
+![Events in PostHog.](./Events_posthog.png)
+
+
+PostHog sends more events, as it integrates deeper in the provided code,
+you can nevertheless see the main events that we have created in our code.
+
+
+## Add a new analytics provider
+
+To add another analytics provider, you need to implement two interfaces, `TrackingSpi` and `TrackingApi`.
+Most easy is probably to copy the `ConsoleTrackingProvider`.
+The first thing you should do is to provide a correct value in `getKey()`.
+Once you are happy enough with the implementation, you should add it in `getTrackingProviders()` to the array of providers.
+
+
+
diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Analytics/Events.png b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Analytics/Events.png
new file mode 100644
index 00000000..41a7a062
Binary files /dev/null and b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Analytics/Events.png differ
diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Analytics/Events_Segment.png b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Analytics/Events_Segment.png
new file mode 100644
index 00000000..a220057b
Binary files /dev/null and b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Analytics/Events_Segment.png differ
diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Analytics/Events_posthog.png b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Analytics/Events_posthog.png
new file mode 100644
index 00000000..dd07a7a6
Binary files /dev/null and b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Analytics/Events_posthog.png differ
diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Analytics/Events_umami.png b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Analytics/Events_umami.png
new file mode 100644
index 00000000..ddb8fa01
Binary files /dev/null and b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Analytics/Events_umami.png differ
diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Analytics/analytics-layout.svg b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Analytics/analytics-layout.svg
new file mode 100644
index 00000000..c49c7522
--- /dev/null
+++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Analytics/analytics-layout.svg
@@ -0,0 +1,150 @@
+
+
+
diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md
index e657d3a0..52b0a0e8 100644
--- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md
+++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md
@@ -57,6 +57,8 @@ import PFIconLogoColor from '../UI/PF-IconLogo-Color.svg';
import PFIconLogoReverse from '../UI/PF-IconLogo-Reverse.svg';
import userAvatar from '../Messages/user_avatar.svg';
import patternflyAvatar from '../Messages/patternfly_avatar.jpg';
+import { getTrackingProviders } from "@patternfly/chatbot/dist/dynamic/tracking";
+
### Basic ChatBot
@@ -81,6 +83,7 @@ This demo displays a basic ChatBot, which includes:
7. A "Skip to chatbot" button that allows you to skip to the chatbot content via the [PatternFly skip to content component](/patternfly-ai/chatbot/ui#skip-to-content). To display this button you must tab into the main window.
+
```js file="./Chatbot.tsx" isFullscreen
```
@@ -126,3 +129,7 @@ Your code structure should look like this:
```js file="./EmbeddedComparisonChatbot.tsx" isFullscreen
```
+
+
+### Analytics
+
diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.tsx
index 7c43b73b..0ed8bc72 100644
--- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.tsx
+++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.tsx
@@ -32,6 +32,8 @@ import PFIconLogoColor from '../UI/PF-IconLogo-Color.svg';
import PFIconLogoReverse from '../UI/PF-IconLogo-Reverse.svg';
import userAvatar from '../Messages/user_avatar.svg';
import patternflyAvatar from '../Messages/patternfly_avatar.jpg';
+import { getTrackingProviders } from '@patternfly/chatbot/dist/dynamic/tracking';
+import { InitProps } from '@patternfly/chatbot/dist/dynamic/tracking/';
const footnoteProps = {
label: 'ChatBot uses AI. Check for mistakes.',
@@ -95,6 +97,20 @@ export default MessageLoading;
// The timestamps re-render with them.
const date = new Date();
+const initProps: InitProps = {
+ segmentKey: 'TODO-key', // TODO add your key here
+ posthogKey: 'TODO-key',
+ umamiKey: 'TODO-key',
+ umamiHostUrl: 'http://localhost:3000', // TODO where is your JS provider?
+ console: true,
+ something: 'test'
+};
+
+const tracking = getTrackingProviders(initProps);
+tracking.identify('user-123'); // TODO get real user id
+tracking.trackPageView();
+
+const actionEventName = 'MessageAction';
const initialMessages: MessageProps[] = [
{
id: '1',
@@ -113,16 +129,11 @@ const initialMessages: MessageProps[] = [
avatar: patternflyAvatar,
timestamp: date.toLocaleString(),
actions: {
- // eslint-disable-next-line no-console
- positive: { onClick: () => console.log('Good response') },
- // eslint-disable-next-line no-console
- negative: { onClick: () => console.log('Bad response') },
- // eslint-disable-next-line no-console
- copy: { onClick: () => console.log('Copy') },
- // eslint-disable-next-line no-console
- share: { onClick: () => console.log('Share') },
- // eslint-disable-next-line no-console
- listen: { onClick: () => console.log('Listen') }
+ positive: { onClick: () => tracking.trackSingleItem(actionEventName, { response: 'Good response' }) },
+ negative: { onClick: () => tracking.trackSingleItem(actionEventName, { response: 'Bad response' }) },
+ copy: { onClick: () => tracking.trackSingleItem(actionEventName, { response: 'Copy' }) },
+ share: { onClick: () => tracking.trackSingleItem(actionEventName, { response: 'Share' }) },
+ listen: { onClick: () => tracking.trackSingleItem(actionEventName, { response: 'Listen' }) }
}
}
];
@@ -164,6 +175,7 @@ const initialConversations = {
]
};
+const actionEvent2 = 'ActionEvent2';
export const ChatbotDemo: React.FunctionComponent = () => {
const [chatbotVisible, setChatbotVisible] = React.useState(true);
const [displayMode, setDisplayMode] = React.useState(ChatbotDisplayMode.default);
@@ -180,7 +192,7 @@ export const ChatbotDemo: React.FunctionComponent = () => {
const chatbotRef = React.useRef(null);
const historyRef = React.useRef(null);
- // Autu-scrolls to the latest message
+ // Auto-scrolls to the latest message
React.useEffect(() => {
// don't scroll the first load - in this demo, we know we start with two messages
if (messages.length > 2) {
@@ -193,6 +205,7 @@ export const ChatbotDemo: React.FunctionComponent = () => {
value: string | number | undefined
) => {
setSelectedModel(value as string);
+ tracking.trackSingleItem('ModelSelected', { model: value });
};
const onSelectDisplayMode = (
@@ -210,6 +223,7 @@ export const ChatbotDemo: React.FunctionComponent = () => {
const handleSend = (message: string) => {
setIsSendButtonDisabled(true);
+ tracking.trackSingleItem('UserInputReceived', { text: message });
const newMessages: MessageProps[] = [];
// We can't use structuredClone since messages contains functions, but we can't mutate
// items that are going into state or the UI won't update correctly
@@ -255,21 +269,17 @@ export const ChatbotDemo: React.FunctionComponent = () => {
avatar: patternflyAvatar,
timestamp: date.toLocaleString(),
actions: {
- // eslint-disable-next-line no-console
- positive: { onClick: () => console.log('Good response') },
- // eslint-disable-next-line no-console
- negative: { onClick: () => console.log('Bad response') },
- // eslint-disable-next-line no-console
- copy: { onClick: () => console.log('Copy') },
- // eslint-disable-next-line no-console
- share: { onClick: () => console.log('Share') },
- // eslint-disable-next-line no-console
- listen: { onClick: () => console.log('Listen') }
+ positive: { onClick: () => tracking.trackSingleItem(actionEvent2, { response: 'Good response' }) },
+ negative: { onClick: () => tracking.trackSingleItem(actionEvent2, { response: 'Bad response' }) },
+ copy: { onClick: () => tracking.trackSingleItem(actionEvent2, { response: 'Copy' }) },
+ share: { onClick: () => tracking.trackSingleItem(actionEvent2, { response: 'Share' }) },
+ listen: { onClick: () => tracking.trackSingleItem(actionEvent2, { response: 'Listen' }) }
}
});
setMessages(loadedMessages);
// make announcement to assistive devices that new message has loaded
setAnnouncement(`Message from Bot: API response goes here`);
+ tracking.trackSingleItem('BotResponded', { undefined });
setIsSendButtonDisabled(false);
}, 5000);
};
@@ -339,7 +349,10 @@ export const ChatbotDemo: React.FunctionComponent = () => {
setChatbotVisible(!chatbotVisible)}
+ onToggleChatbot={function () {
+ setChatbotVisible(!chatbotVisible);
+ tracking.trackSingleItem('Chatbot Visible', { isVisible: !chatbotVisible }); // TODO correct?
+ }}
id="chatbot-toggle"
ref={toggleRef}
/>
@@ -441,9 +454,9 @@ export const ChatbotDemo: React.FunctionComponent = () => {
prompts={welcomePrompts}
/>
{/* This code block enables scrolling to the top of the last message.
- You can instead choose to move the div with scrollToBottomRef on it below
+ You can instead choose to move the div with scrollToBottomRef on it below
the map of messages, so that users are forced to scroll to the bottom.
- If you are using streaming, you will want to take a different approach;
+ If you are using streaming, you will want to take a different approach;
see: https://github.com/patternfly/chatbot/issues/201#issuecomment-2400725173 */}
{messages.map((message, index) => {
if (index === messages.length - 1) {
diff --git a/packages/module/src/index.ts b/packages/module/src/index.ts
index 6e1cc449..cc11b8d7 100644
--- a/packages/module/src/index.ts
+++ b/packages/module/src/index.ts
@@ -80,3 +80,6 @@ export * from './SourcesCard';
export { default as TermsOfUse } from './TermsOfUse';
export * from './TermsOfUse';
+
+export { default as tracking } from './tracking';
+export * from './tracking';
diff --git a/packages/module/src/tracking/console_tracking_provider.ts b/packages/module/src/tracking/console_tracking_provider.ts
new file mode 100644
index 00000000..178b25b7
--- /dev/null
+++ b/packages/module/src/tracking/console_tracking_provider.ts
@@ -0,0 +1,30 @@
+import { TrackingSpi } from './tracking_spi';
+import { TrackingApi, TrackingEventProperties } from './tracking_api';
+
+export class ConsoleTrackingProvider implements TrackingSpi, TrackingApi {
+ trackPageView(url: string | undefined) {
+ // eslint-disable-next-line no-console
+ console.log('ConsoleProvider pageView', url);
+ }
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ registerProvider(): void {}
+
+ initialize(): void {
+ // eslint-disable-next-line no-console
+ console.log('ConsoleProvider initialize');
+ }
+
+ identify(userID: string): void {
+ // eslint-disable-next-line no-console
+ console.log('ConsoleProvider identify', userID);
+ }
+
+ trackSingleItem(item: string, properties?: TrackingEventProperties): void {
+ // eslint-disable-next-line no-console
+ console.log('ConsoleProvider: ' + item, properties);
+ }
+
+ getKey(): string {
+ return 'console';
+ }
+}
diff --git a/packages/module/src/tracking/index.ts b/packages/module/src/tracking/index.ts
new file mode 100644
index 00000000..b50c1e43
--- /dev/null
+++ b/packages/module/src/tracking/index.ts
@@ -0,0 +1,3 @@
+export { default } from './tracking_registry';
+
+export * from './tracking_registry';
diff --git a/packages/module/src/tracking/posthog_tracking_provider.ts b/packages/module/src/tracking/posthog_tracking_provider.ts
new file mode 100644
index 00000000..fb8c7b93
--- /dev/null
+++ b/packages/module/src/tracking/posthog_tracking_provider.ts
@@ -0,0 +1,42 @@
+import { posthog } from 'posthog-js';
+
+import { TrackingApi, TrackingEventProperties } from './tracking_api';
+import { InitProps, TrackingSpi } from './tracking_spi';
+
+export class PosthogTrackingProvider implements TrackingSpi, TrackingApi {
+ getKey(): string {
+ return 'posthogKey';
+ }
+
+ initialize(props: InitProps): void {
+ // eslint-disable-next-line no-console
+ console.log('PosthogProvider initialize');
+ const posthogKey = props.posthogKey as string;
+
+ posthog.init(posthogKey, {
+ // eslint-disable-next-line camelcase
+ api_host: 'https://us.i.posthog.com',
+ // eslint-disable-next-line camelcase
+ person_profiles: 'identified_only' // or 'always' to create profiles for anonymous users as well
+ });
+ }
+
+ identify(userID: string): void {
+ // eslint-disable-next-line no-console
+ console.log('PosthogProvider userID: ' + userID);
+ posthog.identify(userID);
+ }
+
+ trackPageView(url: string | undefined): void {
+ // eslint-disable-next-line no-console
+ console.log('PostHogProvider url', url);
+ // TODO posthog seems to record that automatically.
+ // How to not clash with this here? Just leave as no-op?
+ }
+
+ trackSingleItem(item: string, properties?: TrackingEventProperties): void {
+ // eslint-disable-next-line no-console
+ console.log('PosthogProvider: trackSingleItem' + item, properties);
+ posthog.capture(item, { properties });
+ }
+}
diff --git a/packages/module/src/tracking/segment_tracking_provider.ts b/packages/module/src/tracking/segment_tracking_provider.ts
new file mode 100644
index 00000000..a9abaf0e
--- /dev/null
+++ b/packages/module/src/tracking/segment_tracking_provider.ts
@@ -0,0 +1,58 @@
+import { AnalyticsBrowser } from '@segment/analytics-next';
+
+import { TrackingApi, TrackingEventProperties } from './tracking_api';
+import { InitProps, TrackingSpi } from './tracking_spi';
+
+export class SegmentTrackingProvider implements TrackingSpi, TrackingApi {
+ private analytics: AnalyticsBrowser | undefined;
+ getKey(): string {
+ return 'segmentKey';
+ }
+
+ initialize(props: InitProps): void {
+ // eslint-disable-next-line no-console
+ console.log('SegmentProvider initialize');
+ const segmentKey = props.segmentKey as string;
+
+ this.analytics = AnalyticsBrowser.load(
+ {
+ writeKey: segmentKey,
+ cdnURL: props.segmentCdn as string
+ },
+
+ {
+ integrations: {
+ ...props.segmentIntegrations
+ }
+ }
+ );
+ }
+
+ identify(userID: string): void {
+ // eslint-disable-next-line no-console
+ console.log('SegmentProvider userID: ' + userID);
+ if (this.analytics) {
+ this.analytics.identify(userID);
+ }
+ }
+
+ trackPageView(url: string | undefined): void {
+ // eslint-disable-next-line no-console
+ console.log('SegmentProvider url', url);
+ if (this.analytics) {
+ if (url) {
+ this.analytics.page(url);
+ } else {
+ this.analytics.page(); // Uses window.url
+ }
+ }
+ }
+
+ trackSingleItem(item: string, properties?: TrackingEventProperties): void {
+ // eslint-disable-next-line no-console
+ console.log('SegmentProvider: trackSingleItem' + item, properties);
+ if (this.analytics) {
+ this.analytics.track(item, { properties });
+ }
+ }
+}
diff --git a/packages/module/src/tracking/trackingProviderProxy.ts b/packages/module/src/tracking/trackingProviderProxy.ts
new file mode 100644
index 00000000..e2c78d5c
--- /dev/null
+++ b/packages/module/src/tracking/trackingProviderProxy.ts
@@ -0,0 +1,28 @@
+import { TrackingApi, TrackingEventProperties } from './tracking_api';
+class TrackingProviderProxy implements TrackingApi {
+ providers: TrackingApi[] = [];
+
+ constructor(providers: TrackingApi[]) {
+ this.providers = providers;
+ }
+
+ identify(userID: string): void {
+ for (const provider of this.providers) {
+ provider.identify(userID);
+ }
+ }
+
+ trackSingleItem(eventName: string, properties?: TrackingEventProperties): void {
+ for (const provider of this.providers) {
+ provider.trackSingleItem(eventName, properties);
+ }
+ }
+
+ trackPageView(url: string | undefined): void {
+ for (const provider of this.providers) {
+ provider.trackPageView(url);
+ }
+ }
+}
+
+export default TrackingProviderProxy;
diff --git a/packages/module/src/tracking/tracking_api.ts b/packages/module/src/tracking/tracking_api.ts
new file mode 100644
index 00000000..fef0fdc7
--- /dev/null
+++ b/packages/module/src/tracking/tracking_api.ts
@@ -0,0 +1,11 @@
+export interface TrackingEventProperties {
+ [key: string]: string | number | boolean | undefined;
+}
+
+export interface TrackingApi {
+ identify: (userID: string) => void;
+
+ trackPageView: (url: string | undefined) => void;
+
+ trackSingleItem: (eventName: string, properties: TrackingEventProperties | undefined) => void;
+}
diff --git a/packages/module/src/tracking/tracking_registry.ts b/packages/module/src/tracking/tracking_registry.ts
new file mode 100644
index 00000000..6f35bedc
--- /dev/null
+++ b/packages/module/src/tracking/tracking_registry.ts
@@ -0,0 +1,33 @@
+import { InitProps, TrackingSpi } from './tracking_spi';
+import { TrackingApi } from './tracking_api';
+import TrackingProviderProxy from './trackingProviderProxy';
+import { ConsoleTrackingProvider } from './console_tracking_provider';
+import { SegmentTrackingProvider } from './segment_tracking_provider';
+import { PosthogTrackingProvider } from './posthog_tracking_provider';
+import { UmamiTrackingProvider } from './umami_tracking_provider';
+
+export const getTrackingProviders = (initProps: InitProps): TrackingApi => {
+ const providers: TrackingSpi[] = [];
+ providers.push(new SegmentTrackingProvider());
+ providers.push(new PosthogTrackingProvider());
+ providers.push(new UmamiTrackingProvider());
+
+ // TODO dynamically find and register providers
+
+ // Initialize them
+ const enabledProviders: TrackingSpi[] = [];
+ for (const provider of providers) {
+ const key = provider.getKey();
+ if (Object.keys(initProps).indexOf(key) > -1) {
+ provider.initialize(initProps);
+ enabledProviders.push(provider);
+ }
+ }
+ // Add the console provider
+ const consoleTrackingProvider = new ConsoleTrackingProvider();
+ enabledProviders.push(consoleTrackingProvider); // TODO noop- provider?
+
+ return new TrackingProviderProxy(enabledProviders);
+};
+
+export default getTrackingProviders;
diff --git a/packages/module/src/tracking/tracking_spi.ts b/packages/module/src/tracking/tracking_spi.ts
new file mode 100644
index 00000000..7045dc14
--- /dev/null
+++ b/packages/module/src/tracking/tracking_spi.ts
@@ -0,0 +1,14 @@
+import { TrackingApi, TrackingEventProperties } from './tracking_api';
+
+export interface InitProps {
+ [key: string]: string | number | boolean;
+}
+
+export interface TrackingSpi extends TrackingApi {
+ // Return a key in InitProps to check if the provided should be enabled
+ getKey: () => string;
+ // Initialize the provider
+ initialize: (props: InitProps) => void;
+ // Track a single item
+ trackSingleItem: (item: string, properties?: TrackingEventProperties) => void;
+}
diff --git a/packages/module/src/tracking/umami_tracking_provider.ts b/packages/module/src/tracking/umami_tracking_provider.ts
new file mode 100644
index 00000000..f13d03fd
--- /dev/null
+++ b/packages/module/src/tracking/umami_tracking_provider.ts
@@ -0,0 +1,53 @@
+import { InitProps, TrackingSpi } from './tracking_spi';
+import { TrackingApi, TrackingEventProperties } from './tracking_api';
+
+declare global {
+ interface Window {
+ umami: any;
+ }
+}
+
+export class UmamiTrackingProvider implements TrackingSpi, TrackingApi {
+ getKey(): string {
+ return 'umamiKey';
+ }
+
+ initialize(props: InitProps): void {
+ // eslint-disable-next-line no-console
+ console.log('UmamiProvider initialize');
+ const umamiKey = props.umamiKey as string;
+ const hostUrl = props.umamiHostUrl as string;
+
+ const script = document.createElement('script');
+ script.src = hostUrl + '/script.js';
+ script.async = true;
+ script.defer = true;
+
+ // Configure Umami properties
+ script.setAttribute('data-website-id', umamiKey);
+ script.setAttribute('data-domains', 'localhost'); // TODO ?
+ script.setAttribute('data-auto-track', 'false');
+ script.setAttribute('data-host-url', hostUrl); // TODO ?
+ script.setAttribute('data-exclude-search', 'false'); // TODO ?
+
+ document.body.appendChild(script);
+ }
+
+ identify(userID: string): void {
+ // eslint-disable-next-line no-console
+ console.log('UmamiProvider userID: ' + userID);
+ window.umami?.identify({ userID });
+ }
+
+ trackPageView(url: string | undefined): void {
+ // eslint-disable-next-line no-console
+ console.log('UmamiProvider url', url);
+ window.umami?.track({ url });
+ }
+
+ trackSingleItem(item: string, properties?: TrackingEventProperties): void {
+ // eslint-disable-next-line no-console
+ console.log('UmamiProvider: trackSingleItem' + item, properties);
+ window.umami?.track(item, properties);
+ }
+}