diff --git a/docs/service_table.md b/docs/service_table.md index 04c00e864d..6bcc547edc 100644 --- a/docs/service_table.md +++ b/docs/service_table.md @@ -10,7 +10,7 @@ View [Service Graph](../README.md#architecture) to visualize request flows. | [currencyservice](../src/currencyservice/README.md) | C++ | Converts one money amount to another currency. Uses real values fetched from European Central Bank. It's the highest QPS service. | | [emailservice](./services/emailservice.md) | Ruby | Sends users an order confirmation email (mock). | | [featureflagservice](./services/featureflagservice.md) | Erlang/Elixir | CRUD feature flag service to demonstrate various scenarios like fault injection & how to emit telemetry from a feature flag reliant service. | -| [frontend](../src/frontend/README.md) | JavaScript | Exposes an HTTP server to serve the website. Does not require signup/login and generates session IDs for all users automatically. | +| [frontend](./services/frontend.md) | JavaScript | Exposes an HTTP server to serve the website. Does not require signup/login and generates session IDs for all users automatically. | | [loadgenerator](./services/loadgenerator.md) | Python/Locust | Continuously sends requests imitating realistic user shopping flows to the frontend. | | [paymentservice](./services/paymentservice.md) | JavaScript | Charges the given credit card info (mock) with the given amount and returns a transaction ID. | | [productcatalogservice](./services/productcatalogservice.md) | Go | Provides the list of products from a JSON file and ability to search products and get individual products. | diff --git a/docs/services/frontend.md b/docs/services/frontend.md new file mode 100644 index 0000000000..6f25066133 --- /dev/null +++ b/docs/services/frontend.md @@ -0,0 +1,174 @@ +# frontend + +The frontend is responsible to provide a UI for users, as well +as an API leveraged by the UI or other clients. The application is based on +[Next.JS](https://nextjs.org/) to provide a React web-based UI and API routes. + +[Frontend source](../../src/frontend/) + +## SDK initialization + +It is recommended to use a Node required module when starting your NodeJS +application to initialize the SDK and auto-instrumentation. When initializing +the OpenTelemetry NodeJS SDK, you optionally specify which auto-instrumentation +libraries to leverage, or make use of the `getNodeAutoInstrumentations()` +function which includes most popular frameworks. The `utils/telemetry/Instrumentation.js` +file contains all code required to initialize the SDK and auto-instrumentation +based on standard [OpenTelemetry environment variables](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/sdk-environment-variables.md) +for OTLP export, resource attributes, and service name. + +```javascript +const opentelemetry = require("@opentelemetry/sdk-node") +const { getNodeAutoInstrumentations } = require("@opentelemetry/auto-instrumentations-node") +const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc') + +const sdk = new opentelemetry.NodeSDK({ + traceExporter: new OTLPTraceExporter(), + instrumentations: [ getNodeAutoInstrumentations() ] +}) + +sdk.start() +``` + +Node required modules are loaded using the `--require` command line argument. +This can be done in the `scripts.start` section of `package.json` and starting +the application using `npm start`. + +```json + "scripts": { + "start": "node --require ./Instrumentation.js server.js", + }, +``` + +## Traces + +### Span Exceptions and status + +You can use the span object's `recordException` function to create a span event +with the full stack trace of a handled error. When recording an exception also +be sure to set the span's status accordingly. You can see this in the catch +block of the `NextApiHandler` function in the `utils/telemetry/InstrumentationMiddleware.ts` +file. + +```typescript + span.recordException(error as Exception); + span.setStatus({code: SpanStatusCode.ERROR}); +``` + +### Create new spans + +New spans can be created and started using `Tracer.startSpan("spanName", options)`. +Several options can be used to specify how the span can be created. + +- `root: true` will create a new trace, setting this span as the root. +- `links` are used to specify links to other spans (even within another trace) +that should be referenced. +- `attributes` are key/value pairs added to a span, typically used for +application context. + +```typescript + span = tracer.startSpan(`HTTP ${method}`, { + root: true, + kind: SpanKind.SERVER, + links: [{context: syntheticSpan.spanContext()}], + attributes: { + "app.synthetic_request": true, + [SemanticAttributes.HTTP_TARGET]: target, + [SemanticAttributes.HTTP_STATUS_CODE]: response.statusCode, + [SemanticAttributes.HTTP_ROUTE]: url, + [SemanticAttributes.HTTP_METHOD]: method, + [SemanticAttributes.HTTP_USER_AGENT]: headers['user-agent'] || '', + [SemanticAttributes.HTTP_URL]: `${headers.host}${url}`, + [SemanticAttributes.HTTP_FLAVOR]: httpVersion, + } + }); +``` + +## Browser Instrumentation + +The web-based UI that the frontend provides is also instrumented for web +browsers. OpenTelemetry instrumentation is included as part of the Next.js App +component in `pages/_app.tsx`. Here instrumentation is imported and +initialized. + +```typescript +import FrontendTracer from '../utils/telemetry/FrontendTracer'; + +if (typeof window !== 'undefined') FrontendTracer(); +``` + +The `utils/telemetry/FrontendTracer.ts` file contains code to intialize a +TracerProvider, establish an OTLP export, register trace context propagators, +and register web specific auto-instrumentation libraries. Since the browser +will send data to an OpenTelemetry collector that will likely be on a separate +domain, CORS headers are also setup accordingly. + +```typescript +import { CompositePropagator, W3CBaggagePropagator, W3CTraceContextPropagator } from '@opentelemetry/core'; +import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'; +import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; +import { getWebAutoInstrumentations } from '@opentelemetry/auto-instrumentations-web'; +import { Resource } from '@opentelemetry/resources'; +import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; + +const FrontendTracer = async () => { + const { ZoneContextManager } = await import('@opentelemetry/context-zone'); + + const provider = new WebTracerProvider({ + resource: new Resource({ + [SemanticResourceAttributes.SERVICE_NAME]: process.env.NEXT_PUBLIC_OTEL_SERVICE_NAME, + }), + }); + + provider.addSpanProcessor(new SimpleSpanProcessor(new OTLPTraceExporter())); + + const contextManager = new ZoneContextManager(); + + provider.register({ + contextManager, + propagator: new CompositePropagator({ + propagators: [new W3CBaggagePropagator(), new W3CTraceContextPropagator()], + }), + }); + + registerInstrumentations({ + tracerProvider: provider, + instrumentations: [ + getWebAutoInstrumentations({ + '@opentelemetry/instrumentation-fetch': { + propagateTraceHeaderCorsUrls: /.*/, + clearTimingResources: true, + }, + }), + ], + }); +}; + +export default FrontendTracer; +``` + +## Metrics + +TBD + +## Logs + +TBD + +## Baggage + +OpenTelemetry Baggage is leveraged in the frontend to check if the request is +synthetic (from the load generator). Synthetic requests will force the creation +of a new trace. The root span from the new trace will contain many of the same +attributes as an HTTP request instrumented span. + +To determine if a Baggage item is set, you can leverage the `propagation` API +to parse the Baggage header, and leverage the `baggage` API to get or +set entries. + +```typescript + const baggage = propagation.getBaggage(context.active()); + if (baggage?.getEntry("synthetic_request")?.value == "true") {...} +``` diff --git a/src/frontend/utils/telemetry/BackendTracer.ts b/src/frontend/utils/telemetry/BackendTracer.ts deleted file mode 100644 index e2416244a7..0000000000 --- a/src/frontend/utils/telemetry/BackendTracer.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { context, trace, Tracer, Span, SpanStatusCode, Exception } from '@opentelemetry/api'; - -interface ITracer { - getTracer(): Tracer; - runWithSpan(parentSpan: Span, fn: () => Promise): Promise; -} - -const BackendTracer = (): ITracer => ({ - getTracer() { - return trace.getTracer(process.env.OTEL_SERVICE_NAME as string); - }, - async runWithSpan(parentSpan, fn) { - const ctx = trace.setSpan(context.active(), parentSpan); - - try { - return await context.with(ctx, fn); - } catch (error) { - parentSpan.recordException(error as Exception); - parentSpan.setStatus({ code: SpanStatusCode.ERROR }); - - throw error; - } - }, -}); - -export default BackendTracer(); diff --git a/src/frontend/utils/telemetry/Instrumentation.js b/src/frontend/utils/telemetry/Instrumentation.js index 8fe36808c2..63b2a2329f 100644 --- a/src/frontend/utils/telemetry/Instrumentation.js +++ b/src/frontend/utils/telemetry/Instrumentation.js @@ -1,17 +1,10 @@ -const { CompositePropagator, W3CBaggagePropagator, W3CTraceContextPropagator } = require('@opentelemetry/core'); -const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc'); -const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node'); -const { NodeSDK, api } = require('@opentelemetry/sdk-node'); +const opentelemetry = require("@opentelemetry/sdk-node") +const { getNodeAutoInstrumentations } = require("@opentelemetry/auto-instrumentations-node") +const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc') -api.propagation.setGlobalPropagator( - new CompositePropagator({ - propagators: [new W3CBaggagePropagator(), new W3CTraceContextPropagator()], - }) -); - -const sdk = new NodeSDK({ +const sdk = new opentelemetry.NodeSDK({ traceExporter: new OTLPTraceExporter(), - instrumentations: getNodeAutoInstrumentations(), -}); + instrumentations: [ getNodeAutoInstrumentations() ] +}) -sdk.start(); +sdk.start() diff --git a/src/frontend/utils/telemetry/InstrumentationMiddleware.ts b/src/frontend/utils/telemetry/InstrumentationMiddleware.ts index 85ced4f430..9fdec51d29 100644 --- a/src/frontend/utils/telemetry/InstrumentationMiddleware.ts +++ b/src/frontend/utils/telemetry/InstrumentationMiddleware.ts @@ -1,8 +1,6 @@ import {NextApiHandler} from 'next'; -import Tracer from './BackendTracer'; -import {context, Exception, propagation, SpanKind, SpanStatusCode, trace} from '@opentelemetry/api'; +import {context, Exception, propagation, Span, SpanKind, SpanStatusCode, trace} from '@opentelemetry/api'; import {SemanticAttributes} from '@opentelemetry/semantic-conventions'; -import {Span} from '@opentelemetry/sdk-trace-base'; const InstrumentationMiddleware = (handler: NextApiHandler): NextApiHandler => { return async (request, response) => { @@ -15,7 +13,8 @@ const InstrumentationMiddleware = (handler: NextApiHandler): NextApiHandler => { // if synthetic_request baggage is set, create a new trace linked to the span in context // this span will look similar to the auto-instrumented HTTP span const syntheticSpan = trace.getSpan(context.active()) as Span; - span = Tracer.getTracer().startSpan(`HTTP ${method}`, { + const tracer = trace.getTracer(process.env.OTEL_SERVICE_NAME as string); + span = tracer.startSpan(`HTTP ${method}`, { root: true, kind: SpanKind.SERVER, links: [{context: syntheticSpan.spanContext()}], @@ -37,7 +36,7 @@ const InstrumentationMiddleware = (handler: NextApiHandler): NextApiHandler => { } try { - await Tracer.runWithSpan(span, async () => handler(request, response)); + await runWithSpan(span, async () => handler(request, response)); } catch (error) { span.recordException(error as Exception); span.setStatus({code: SpanStatusCode.ERROR}); @@ -48,4 +47,17 @@ const InstrumentationMiddleware = (handler: NextApiHandler): NextApiHandler => { }; }; +async function runWithSpan(parentSpan: Span, fn: () => Promise) { + const ctx = trace.setSpan(context.active(), parentSpan); + + try { + return await context.with(ctx, fn); + } catch (error) { + parentSpan.recordException(error as Exception); + parentSpan.setStatus({ code: SpanStatusCode.ERROR }); + + throw error; + } +} + export default InstrumentationMiddleware;