From 517c967718f6c55c2c4cbc0597957bfc9bea186a Mon Sep 17 00:00:00 2001 From: Michael Beemer Date: Fri, 8 Nov 2024 20:22:17 +0000 Subject: [PATCH] feat: add tracking to ToggleShop Signed-off-by: Michael Beemer --- src/app/api/events/track/route.ts | 10 +- src/app/api/orders/route.ts | 6 + src/app/checkout/page.tsx | 310 ++++++++++--------- src/hooks/use-products.tsx | 2 +- src/libs/open-feature/client-event-hook.ts | 1 - src/libs/open-feature/send-tracking-event.ts | 12 + src/providers/open-feature.tsx | 32 +- src/register.openfeature.ts | 29 +- 8 files changed, 240 insertions(+), 162 deletions(-) create mode 100644 src/libs/open-feature/send-tracking-event.ts diff --git a/src/app/api/events/track/route.ts b/src/app/api/events/track/route.ts index 4f4cd9f..40c1af1 100644 --- a/src/app/api/events/track/route.ts +++ b/src/app/api/events/track/route.ts @@ -1,16 +1,10 @@ "use server"; -import { events } from "@opentelemetry/api-events"; - -const featureFlagTrack = events.getEventLogger("feature_flag"); +import { sendTrackEvent } from "@/libs/open-feature/send-tracking-event"; export async function POST(request: Request) { const attributes = await request.json(); - featureFlagTrack.emit({ - name: "feature_flag.track", - attributes, - }); - + sendTrackEvent(attributes); return new Response(null, { status: 202 }); } diff --git a/src/app/api/orders/route.ts b/src/app/api/orders/route.ts index 46c246b..9f42e5a 100644 --- a/src/app/api/orders/route.ts +++ b/src/app/api/orders/route.ts @@ -1,10 +1,16 @@ "use server"; import { NextResponse } from "next/server"; +import { OpenFeature } from "@openfeature/server-sdk"; +import { headerToEvaluationContext } from "@/libs/open-feature/evaluation-context"; export async function POST(request: Request) { + const featureFlagClient = OpenFeature.getClient( + headerToEvaluationContext(request.headers) + ); const order = await request.json(); console.log("Order received:", order); + featureFlagClient.track("order_received"); return NextResponse.json({ message: "Order received successfully" }); } diff --git a/src/app/checkout/page.tsx b/src/app/checkout/page.tsx index 12068db..d981bad 100644 --- a/src/app/checkout/page.tsx +++ b/src/app/checkout/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useReducer } from "react"; +import { useMemo, useReducer } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; @@ -9,78 +9,20 @@ import { useCart } from "@/hooks/use-cart"; import Header from "@/components/Header"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { tanstackMetaToHeader } from "@/libs/open-feature/evaluation-context"; -import { useFlag } from "@openfeature/react-sdk"; - -interface FormState { - name: string; - email: string; - address: string; - card: string; -} - -type FormAction = { type: "SET_FIELD"; field: keyof FormState; value: string }; - -const formReducer = (state: FormState, action: FormAction): FormState => { - switch (action.type) { - case "SET_FIELD": - return { - ...state, - [action.field]: action.value, - }; - default: - return state; - } -}; +import { useFlag, useTrack } from "@openfeature/react-sdk"; export default function Checkout() { - const router = useRouter(); - const { cartItems, removeFromCart, clearCart, updateQuantity } = useCart(); + const { cartItems, removeFromCart, updateQuantity } = useCart(); + const { track } = useTrack(); // This should be validated server-side in a real app const { value: freeShipping, isAuthoritative } = useFlag( "offer-free-shipping", false ); - const [formData, dispatch] = useReducer(formReducer, { - name: "", - email: "", - address: "", - card: "", - }); - - const handleChange = (e: React.ChangeEvent) => { - dispatch({ - type: "SET_FIELD", - field: e.target.name as keyof FormState, - value: e.target.value, - }); - }; - const queryClient = useQueryClient(); - const mutation = useMutation({ - mutationFn: () => { - return fetch("/api/orders", { - method: "POST", - headers: { - "Content-Type": "application/json", - ...tanstackMetaToHeader( - queryClient.getDefaultOptions().mutations?.meta - ), - }, - body: JSON.stringify({ - items: cartItems, - customerInfo: formData, - }), - }); - }, - onError(error) { - console.error("Error submitting order:", error); - alert("There was an error submitting your order. Please try again."); - }, - onSuccess() { - router.push("/order-success"); - clearCart(); - }, - }); + useMemo(() => { + track("visit_checkout"); + }, [track]); const cartPrices = cartItems.reduce( (total, item) => total + item.price * item.quantity, @@ -90,11 +32,6 @@ export default function Checkout() { freeShipping && isAuthoritative && cartPrices > 50 ? 0 : 10; const totalPrice = cartPrices + shippingPrice; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - mutation.mutate(); - }; - return (
@@ -197,85 +134,7 @@ export default function Checkout() { Total: ${totalPrice.toFixed(2)}
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
+ )} @@ -284,3 +143,156 @@ export default function Checkout() { ); } + +interface FormState { + name: string; + email: string; + address: string; + card: string; +} + +type FormAction = { type: "SET_FIELD"; field: keyof FormState; value: string }; + +const formReducer = (state: FormState, action: FormAction): FormState => { + switch (action.type) { + case "SET_FIELD": + return { + ...state, + [action.field]: action.value, + }; + default: + return state; + } +}; + +function CheckoutForm() { + const router = useRouter(); + const { cartItems, clearCart } = useCart(); + + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: () => { + return fetch("/api/orders", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...tanstackMetaToHeader( + queryClient.getDefaultOptions().mutations?.meta + ), + }, + body: JSON.stringify({ + items: cartItems, + customerInfo: formData, + }), + }); + }, + onError(error) { + console.error("Error submitting order:", error); + alert("There was an error submitting your order. Please try again."); + }, + onSuccess() { + router.push("/order-success"); + clearCart(); + }, + }); + + const [formData, dispatch] = useReducer(formReducer, { + name: "", + email: "", + address: "", + card: "", + }); + + const handleChange = (e: React.ChangeEvent) => { + dispatch({ + type: "SET_FIELD", + field: e.target.name as keyof FormState, + value: e.target.value, + }); + }; + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + mutation.mutate(); + }; + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ ); +} diff --git a/src/hooks/use-products.tsx b/src/hooks/use-products.tsx index 5a42508..9e49011 100644 --- a/src/hooks/use-products.tsx +++ b/src/hooks/use-products.tsx @@ -24,7 +24,7 @@ export function useProducts() { } export function useProduct(id: string) { - console.log("fetching products"); + console.log(`fetching product ${id}`); const { data } = useSuspenseQuery({ queryKey: ["products", id], queryFn: async ({ meta }): Promise => { diff --git a/src/libs/open-feature/client-event-hook.ts b/src/libs/open-feature/client-event-hook.ts index 2c72942..c2e3098 100644 --- a/src/libs/open-feature/client-event-hook.ts +++ b/src/libs/open-feature/client-event-hook.ts @@ -32,7 +32,6 @@ export class ClientEventHook implements Hook { hookContext: Readonly>, evaluationDetails: EvaluationDetails ): void | Promise { - console.log("after hook", hookContext); this.sendEvent({ [ATTR_FEATURE_FLAG_KEY]: hookContext.flagKey, [ATTR_FEATURE_FLAG_SYSTEM]: hookContext.providerMetadata.name, diff --git a/src/libs/open-feature/send-tracking-event.ts b/src/libs/open-feature/send-tracking-event.ts new file mode 100644 index 0000000..86557c4 --- /dev/null +++ b/src/libs/open-feature/send-tracking-event.ts @@ -0,0 +1,12 @@ +"use server"; + +import { events, Event } from "@opentelemetry/api-events"; + +const featureFlagTrack = events.getEventLogger("feature_flag"); + +export async function sendTrackEvent(attributes: Event["attributes"]) { + featureFlagTrack.emit({ + name: "feature_flag.track", + attributes, + }); +} diff --git a/src/providers/open-feature.tsx b/src/providers/open-feature.tsx index 6a316f2..882867c 100644 --- a/src/providers/open-feature.tsx +++ b/src/providers/open-feature.tsx @@ -4,10 +4,40 @@ import { EvaluationContext, OpenFeatureProvider as OFProvider, OpenFeature, + Provider, + TrackingEventDetails, } from "@openfeature/react-sdk"; import { OFREPWebProvider } from "@openfeature/ofrep-web-provider"; import { useEffect, useRef } from "react"; import { ClientEventHook } from "@/libs/open-feature/client-event-hook"; +import { getBaseUrl } from "@/libs/url"; +import { ATTR_FEATURE_FLAG_CONTEXT_ID } from "@/libs/open-feature/proposed-attributes"; + +class OFREPWebEventProvider extends OFREPWebProvider implements Provider { + metadata = { name: "OREFP" }; + + track( + trackingEventName: string, + context?: EvaluationContext, + trackingEventDetails?: TrackingEventDetails + ): void { + fetch(getBaseUrl() + "/api/events/track", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ["feature_flag.event_name"]: trackingEventName, + ...(context && + context.targetingKey && { + [ATTR_FEATURE_FLAG_CONTEXT_ID]: context.targetingKey, + }), + ...context, + ...trackingEventDetails, + }), + }).catch(console.error); + } +} export function OpenFeatureProvider({ context, @@ -23,7 +53,7 @@ export function OpenFeatureProvider({ console.log("initializing OFREP provider"); OpenFeature.addHooks(new ClientEventHook()); OpenFeature.setProvider( - new OFREPWebProvider({ + new OFREPWebEventProvider({ baseUrl: "/api", // We're polling for updates frequently for demo purposes. // A real app may want to only update on page load. diff --git a/src/register.openfeature.ts b/src/register.openfeature.ts index 30d392d..606ce9d 100644 --- a/src/register.openfeature.ts +++ b/src/register.openfeature.ts @@ -1,8 +1,33 @@ import { FlagdProvider } from "@openfeature/flagd-provider"; -import { OpenFeature } from "@openfeature/server-sdk"; +import { + EvaluationContext, + OpenFeature, + Provider, + TrackingEventDetails, +} from "@openfeature/server-sdk"; import { ServerEventHook } from "@/libs/open-feature/server-event-hook"; +import { sendTrackEvent } from "@/libs/open-feature/send-tracking-event"; +import { ATTR_FEATURE_FLAG_CONTEXT_ID } from "./libs/open-feature/proposed-attributes"; console.log("registering the OpenFeature provider"); +class FlagdEventProvider extends FlagdProvider implements Provider { + track( + trackingEventName: string, + context?: EvaluationContext, + trackingEventDetails?: TrackingEventDetails + ): void { + sendTrackEvent({ + ["feature_flag.event_name"]: trackingEventName, + ...(context && + context.targetingKey && { + [ATTR_FEATURE_FLAG_CONTEXT_ID]: context.targetingKey, + }), + ...context, + ...trackingEventDetails, + }); + } +} + OpenFeature.addHooks(new ServerEventHook("feature_flag")); -OpenFeature.setProvider(new FlagdProvider({ resolverType: "in-process" })); +OpenFeature.setProvider(new FlagdEventProvider({ resolverType: "in-process" }));