diff --git a/backend/compose.yaml b/backend/compose.yaml index d8ec121..f24f26e 100644 --- a/backend/compose.yaml +++ b/backend/compose.yaml @@ -28,7 +28,9 @@ services: restart: always grafana: - image: grafana/otel-lgtm + build: + context: . + dockerfile: grafana.Dockerfile ports: - '3000:3000' - '4317:4317' diff --git a/backend/grafana.Dockerfile b/backend/grafana.Dockerfile new file mode 100644 index 0000000..fe99abd --- /dev/null +++ b/backend/grafana.Dockerfile @@ -0,0 +1,5 @@ +FROM grafana/otel-lgtm + +COPY tempo-config.yaml . + +CMD /otel-lgtm/run-all.sh \ No newline at end of file diff --git a/backend/tempo-config.yaml b/backend/tempo-config.yaml new file mode 100644 index 0000000..032f04e --- /dev/null +++ b/backend/tempo-config.yaml @@ -0,0 +1,44 @@ +server: + http_listen_port: 3200 + grpc_listen_port: 9096 + +distributor: + receivers: + otlp: + protocols: + grpc: + endpoint: "localhost:4417" + http: + endpoint: "localhost:4418" + +storage: + trace: + backend: local + wal: + path: /tmp/tempo/wal + local: + path: /tmp/tempo/blocks + +metrics_generator: + registry: + # A list of labels that will be added to all generated metrics. + external_labels: + source: tempo + processor: + span_metrics: + enable_target_info: true + dimensions: ['react_client.cold_start', 'react_client.page_name', 'react_client.product_id'] + local_blocks: + filter_server_spans: false + traces_storage: + path: /tmp/tempo/generator/traces + storage: + path: /tmp/tempo/generator/wal + remote_write: + - url: http://localhost:9090/api/v1/write + send_exemplars: true + +overrides: + defaults: + metrics_generator: + processors: [service-graphs, span-metrics, local-blocks] diff --git a/frontend/index.html b/frontend/index.html index 7dcdc5f..851195c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,9 @@ Vite + React + TS +
diff --git a/frontend/package.json b/frontend/package.json index 54732a4..5b41217 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,6 @@ "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.52.1", - "@opentelemetry/exporter-prometheus": "^0.52.1", "@opentelemetry/exporter-trace-otlp-http": "^0.52.1", "@opentelemetry/instrumentation": "^0.52.1", "@opentelemetry/instrumentation-document-load": "^0.39.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 468bbf1..a488e26 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,15 +1,18 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import Home from "./pages/Home"; import ProductDetails from "./pages/ProductDetails"; +import SpansProvider from "./components/SpansProvider/SpansProvider"; const App = () => { return ( - - - } /> - } /> - - + + + + } /> + } /> + + + ); }; diff --git a/frontend/src/components/Page/Page.tsx b/frontend/src/components/Page/Page.tsx new file mode 100644 index 0000000..7be8324 --- /dev/null +++ b/frontend/src/components/Page/Page.tsx @@ -0,0 +1,56 @@ +import { FC, PropsWithChildren, useEffect, useRef } from "react"; +import { Attributes } from "@opentelemetry/api"; +import useTrackPageView from "../../hooks/useTrackPageView"; +import useSpansContext from "../SpansProvider/hooks/useSpansContext"; + +type PageProps = { + isLoading?: boolean; + instrumentation: { + pageName: string; + attributes?: Attributes; + }; +}; + +const Page: FC> = ({ + children, + instrumentation, + isLoading, +}) => { + const wasColdStart = useRef(window.coldStart); + + const { pageName, attributes } = instrumentation; + const { endSpan, getOrCreateSpan } = useSpansContext(); + + getOrCreateSpan("Page Ready", { + startTime: wasColdStart.current + ? performance.timeOrigin + : new Date().getTime(), + attributes: { + "react_client.cold_start": wasColdStart.current, + "react_client.page_name": pageName, + ...attributes, + }, + }); + + useEffect(() => { + if (window.coldStart) { + window.coldStart = false; + } + }, []); + + useEffect(() => { + if (isLoading === undefined || !isLoading) { + endSpan("Page Ready"); + } + }, [endSpan, isLoading]); + + useTrackPageView(pageName, attributes); + + if (isLoading) { + return "Loading"; + } + + return <>{children}; +}; + +export default Page; diff --git a/frontend/src/components/Page/index.ts b/frontend/src/components/Page/index.ts new file mode 100644 index 0000000..39d0be9 --- /dev/null +++ b/frontend/src/components/Page/index.ts @@ -0,0 +1,3 @@ +import Page from "./Page"; + +export default Page; diff --git a/frontend/src/components/SpansProvider/SpansProvider.tsx b/frontend/src/components/SpansProvider/SpansProvider.tsx new file mode 100644 index 0000000..dd929e9 --- /dev/null +++ b/frontend/src/components/SpansProvider/SpansProvider.tsx @@ -0,0 +1,51 @@ +import { SpansContext } from "./hooks/useSpansContext"; +import { FC, PropsWithChildren, useCallback, useRef } from "react"; +import { context, Span, SpanOptions, trace } from "@opentelemetry/api"; +import { SpanName } from "./constants/spans"; + +const SpansProvider: FC = ({ children }) => { + const spansRef = useRef>(new Map()); + + const getOrCreateSpan = useCallback( + (name: SpanName, options?: SpanOptions): [Span, boolean] => { + if (spansRef.current.has(name)) { + return [spansRef.current.get(name)!, false]; + } + + const tracer = trace.getTracer("react-client"); + const span = tracer.startSpan(name, options); + + spansRef.current.set(name, span); + + return [span, true]; + }, + [], + ); + + const withSpanContext = useCallback( + (name: SpanName, callback: () => void) => { + const [span] = getOrCreateSpan(name); + context.with(trace.setSpan(context.active(), span), callback); + }, + [getOrCreateSpan], + ); + + const endSpan = useCallback((name: SpanName) => { + const span = spansRef.current.get(name); + + if (span) { + span.end(); + spansRef.current.delete(name); + } + }, []); + + return ( + + {children} + + ); +}; + +export default SpansProvider; diff --git a/frontend/src/components/SpansProvider/constants/spans.ts b/frontend/src/components/SpansProvider/constants/spans.ts new file mode 100644 index 0000000..1bc8f34 --- /dev/null +++ b/frontend/src/components/SpansProvider/constants/spans.ts @@ -0,0 +1,3 @@ +type SpanName = "Page Ready" | "Purchase Flow"; + +export { type SpanName }; diff --git a/frontend/src/components/SpansProvider/hooks/useSpansContext.ts b/frontend/src/components/SpansProvider/hooks/useSpansContext.ts new file mode 100644 index 0000000..737d1be --- /dev/null +++ b/frontend/src/components/SpansProvider/hooks/useSpansContext.ts @@ -0,0 +1,28 @@ +import { createContext, useContext } from "react"; +import { Span, SpanOptions } from "@opentelemetry/api"; +import { SpanName } from "../constants/spans"; + +type SpansContextValue = { + getOrCreateSpan: (name: SpanName, options?: SpanOptions) => [Span, boolean]; + withSpanContext: (name: SpanName, callback: () => void) => void; + endSpan: (name: SpanName) => void; +}; + +const SpansContext = createContext({ + getOrCreateSpan: () => { + throw new Error("ActiveSpanContextValue not provided"); + }, + withSpanContext: () => { + throw new Error("ActiveSpanContextValue not provided"); + }, + endSpan: () => { + throw new Error("ActiveSpanContextValue not provided"); + }, +}); + +const useSpansContext = () => { + return useContext(SpansContext); +}; + +export default useSpansContext; +export { SpansContext }; diff --git a/frontend/src/hooks/useMeasureReadiness.ts b/frontend/src/hooks/useMeasureReadiness.ts new file mode 100644 index 0000000..d6425ea --- /dev/null +++ b/frontend/src/hooks/useMeasureReadiness.ts @@ -0,0 +1,41 @@ +import { useEffect, useRef } from "react"; +import { Attributes, trace } from "@opentelemetry/api"; + +type UseMeasureReadinessArgs = { + pageName: string; + areNetworkRequestsDone?: boolean; +}; + +const useMeasureReadiness = ( + { pageName, areNetworkRequestsDone }: UseMeasureReadinessArgs, + extraAttributes: Attributes = {}, +) => { + const now = useRef( + window.coldStart ? performance.timeOrigin : new Date().getTime(), + ); + + useEffect(() => { + if (window.coldStart) { + window.coldStart = false; + } + }, []); + + useEffect(() => { + const tracer = trace.getTracer("react-client"); + + if (areNetworkRequestsDone === undefined || areNetworkRequestsDone) { + const span = tracer.startSpan("Page Ready", { + startTime: now.current, + }); + + span.setAttributes({ + page_name: pageName, + ...extraAttributes, + }); + + span.end(); + } + }, [areNetworkRequestsDone]); +}; + +export default useMeasureReadiness; diff --git a/frontend/src/hooks/useTrackPageView.ts b/frontend/src/hooks/useTrackPageView.ts new file mode 100644 index 0000000..5cda208 --- /dev/null +++ b/frontend/src/hooks/useTrackPageView.ts @@ -0,0 +1,24 @@ +import { Attributes, metrics } from "@opentelemetry/api"; +import { useEffect } from "react"; + +const useTrackPageView = ( + pageName: string, + extraAttributes: Attributes = {}, +) => { + useEffect(() => { + const meter = metrics.getMeter("react-client"); + + const counter = meter.createCounter("react_client_page_view", { + description: "Number of views for a page", + }); + + counter.add(1, { + "react_client.page_name": pageName, + ...extraAttributes, + }); + // Make sure this only runs once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +}; + +export default useTrackPageView; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d9ac4fc..5952cb3 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,13 +1,8 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' -import './index.css' -import {setupOTelSDK} from "./otel.ts"; +import ReactDOM from "react-dom/client"; +import App from "./App.tsx"; +import "./index.css"; +import { setupOTelSDK } from "./otel.ts"; setupOTelSDK(); -ReactDOM.createRoot(document.getElementById('root')!).render( - - - , -) +ReactDOM.createRoot(document.getElementById("root")!).render(); diff --git a/frontend/src/otel.ts b/frontend/src/otel.ts index 3f8d36f..cf8fdf7 100644 --- a/frontend/src/otel.ts +++ b/frontend/src/otel.ts @@ -1,8 +1,8 @@ import { Resource } from "@opentelemetry/resources"; import { SEMRESATTRS_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; import { - WebTracerProvider, SimpleSpanProcessor, + WebTracerProvider, } from "@opentelemetry/sdk-trace-web"; import { metrics, trace } from "@opentelemetry/api"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; @@ -13,7 +13,7 @@ import { MeterProvider, PeriodicExportingMetricReader, } from "@opentelemetry/sdk-metrics"; -import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http/build/src/platform/browser"; const setupOTelSDK = () => { const resource = Resource.default().merge( @@ -22,34 +22,37 @@ const setupOTelSDK = () => { }), ); - const provider = new WebTracerProvider({ + const tracerProvider = new WebTracerProvider({ resource: resource, }); - const collectorExporter = new OTLPTraceExporter({ + const traceExporter = new OTLPTraceExporter({ url: "http://localhost:7070/v1/traces", headers: {}, }); - const collectorProcessor = new SimpleSpanProcessor(collectorExporter); + const spanProcessor = new SimpleSpanProcessor(traceExporter); - const metricsCollectorExporter = new OTLPMetricExporter({ + const metricExporter = new OTLPMetricExporter({ url: "http://localhost:7070/v1/metrics", headers: {}, }); const metricReader = new PeriodicExportingMetricReader({ - exporter: metricsCollectorExporter, + exporter: metricExporter, // Default is 60000ms (60 seconds). Set to 10 seconds for demonstrative purposes only. exportIntervalMillis: 10000, }); - const myServiceMeterProvider = new MeterProvider({ + const meterProvider = new MeterProvider({ resource: resource, readers: [metricReader], }); - provider.addSpanProcessor(collectorProcessor); - provider.register(); + metrics.setGlobalMeterProvider(meterProvider); + + tracerProvider.addSpanProcessor(spanProcessor); + tracerProvider.register(); + trace.setGlobalTracerProvider(tracerProvider); registerInstrumentations({ instrumentations: [ @@ -61,9 +64,8 @@ const setupOTelSDK = () => { new DocumentLoadInstrumentation(), ], }); - - metrics.setGlobalMeterProvider(myServiceMeterProvider); - trace.setGlobalTracerProvider(provider); }; export { setupOTelSDK }; + +1721913883463000000; diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index ce9e331..f3b6ec5 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -3,6 +3,8 @@ import ProductsList from "./components/ProductsList/ProductsList"; import styles from "./Home.module.css"; import Container from "../../components/Container/Container"; +import Page from "../../components/Page"; +import useSpansContext from "../../components/SpansProvider/hooks/useSpansContext"; type Product = { id: number; @@ -14,9 +16,20 @@ type Product = { const Home = () => { const [products, setProducts] = useState([]); + const { getOrCreateSpan, withSpanContext } = useSpansContext(); + + const [purchaseFlowSpan, purchaseFlowSpanCreated] = + getOrCreateSpan("Purchase Flow"); + + useEffect(() => { + if (purchaseFlowSpanCreated) { + purchaseFlowSpan.addEvent("Home Page Visited"); + purchaseFlowSpan.setAttribute("user_id", 123); + } + }, [purchaseFlowSpan, purchaseFlowSpanCreated]); useEffect(() => { - const fetchProducts = async () => { + withSpanContext("Page Ready", async () => { try { const response = await fetch("http://localhost:8080/products"); const data = await response.json(); @@ -25,22 +38,21 @@ const Home = () => { } catch (e) { console.error(e); } - }; - - fetchProducts(); - }, []); - - if (products.length === 0) { - return
Loading
; - } + }); + }, [withSpanContext]); return ( - -

- The OpenTelemetry Store -

- -
+ + +

+ The OpenTelemetry Store +

+ +
+
); }; diff --git a/frontend/src/pages/Home/components/ProductsList/components/Product/Product.tsx b/frontend/src/pages/Home/components/ProductsList/components/Product/Product.tsx index 9f2e0bb..8fc057a 100644 --- a/frontend/src/pages/Home/components/ProductsList/components/Product/Product.tsx +++ b/frontend/src/pages/Home/components/ProductsList/components/Product/Product.tsx @@ -5,6 +5,7 @@ import fakeProductPicture from "../../../../../../assets/fake-product-picture.pn import styles from "./Product.module.css"; import { useNavigate } from "react-router-dom"; +import useSpansContext from "../../../../../../components/SpansProvider/hooks/useSpansContext"; type ProductProps = { product: ProductType; @@ -12,8 +13,15 @@ type ProductProps = { const Product: FC = ({ product }) => { const navigate = useNavigate(); + const { getOrCreateSpan } = useSpansContext(); const handleOnClick = () => { + const [activeSpan] = getOrCreateSpan("Purchase Flow"); + + activeSpan.addEvent("Product Clicked", { + product_id: product.id, + }); + navigate(`/products/${product.id}`); }; diff --git a/frontend/src/pages/ProductDetails/ProductDetails.tsx b/frontend/src/pages/ProductDetails/ProductDetails.tsx index 43c4e81..c595887 100644 --- a/frontend/src/pages/ProductDetails/ProductDetails.tsx +++ b/frontend/src/pages/ProductDetails/ProductDetails.tsx @@ -1,18 +1,21 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Product } from "../Home/Home"; import { useParams } from "react-router-dom"; import Container from "../../components/Container/Container"; +import fakeProductPicture from "../../assets/fake-product-picture.png"; +import { metrics, SpanStatusCode } from "@opentelemetry/api"; +import Page from "../../components/Page"; import styles from "./ProductDetails.module.css"; - -import fakeProductPicture from "../../assets/fake-product-picture.png"; +import useSpansContext from "../../components/SpansProvider/hooks/useSpansContext"; const ProductDetails = () => { const [product, setProduct] = useState(null); const { productId } = useParams(); + const { getOrCreateSpan, withSpanContext, endSpan } = useSpansContext(); useEffect(() => { - const fetchProducts = async () => { + withSpanContext("Page Ready", async () => { try { const response = await fetch( `http://localhost:8080/products/${productId}`, @@ -23,36 +26,60 @@ const ProductDetails = () => { } catch (e) { console.error(e); } - }; + }); + }, [productId, withSpanContext]); - fetchProducts(); - }, [productId]); + const handleBuyClicked = useCallback(() => { + const meter = metrics.getMeter("react-client"); + const counter = meter.createCounter("react_client_buy_button_clicked", { + description: "Number of times the buy button was clicked", + unit: "unit", + }); + const [activeSpan] = getOrCreateSpan("Purchase Flow"); - if (!product) { - return "Loading"; - } + activeSpan.addEvent("Buy Button Clicked", { + "react_client.product_id": productId, + }); + activeSpan.setStatus({ code: SpanStatusCode.OK }); + endSpan("Purchase Flow"); + counter.add(1, { + "react_client.product_id": productId, + }); + }, [productId, getOrCreateSpan]); return ( - -

Product Details

-

{product.name}

-
-
- {product.name} -
-
-

{product.description}

-
-
-
- ${product.price} - -
-
+ + {product && ( + +

Product Details

+

{product.name}

+
+
+ {product.name} +
+
+

{product.description}

+
+
+
+ ${product.price} + +
+
+ )} +
); }; diff --git a/frontend/src/window.d.ts b/frontend/src/window.d.ts new file mode 100644 index 0000000..ed6b768 --- /dev/null +++ b/frontend/src/window.d.ts @@ -0,0 +1,7 @@ +export {}; + +declare global { + interface Window { + coldStart: boolean; + } +}