Setting User ID In Next.js And TRPC With Azure Application Insights

by StackCamp Team 68 views

Introduction

In this article, we will explore how to set up user identification in a Next.js and tRPC web application using Azure Application Insights. This is crucial for gaining insights into user behavior, debugging issues, and understanding application performance in a user-centric manner. We'll cover the setup process, including integrating with your authentication system (Auth.js with a database session strategy), and ensuring that user IDs are correctly tracked within Application Insights using OpenTelemetry.js. Leveraging the power of Next.js for the frontend and tRPC for the backend, with Azure as the cloud provider, this comprehensive guide will help you implement robust user tracking in your T3 web application.

Prerequisites

Before diving into the implementation details, ensure you have the following prerequisites in place:

  • A Next.js application: You should have a working Next.js application set up. If you are starting from scratch, you can use create-next-app to bootstrap a new project.
  • tRPC integration: tRPC should be configured in your Next.js application for backend communication. tRPC allows you to build fully typesafe APIs without the need for schemas or code generation.
  • Auth.js set up: Authentication should be implemented using Auth.js with a database session strategy. This will provide the user context that we need to track.
  • Azure account and Application Insights resource: You need an active Azure subscription and an Application Insights resource created in your Azure portal.
  • Basic understanding of OpenTelemetry: Familiarity with OpenTelemetry concepts will be beneficial as we will be using OpenTelemetry.js to send telemetry data to Azure Application Insights.

Setting up Azure Application Insights

To begin, you'll need to configure Azure Application Insights. If you haven't already, create an Application Insights resource in the Azure portal. Once created, you will receive an Instrumentation Key (also known as an iKey), which is a unique identifier for your Application Insights resource. This key is essential for your application to send telemetry data to Azure. You can find the Instrumentation Key in the Overview section of your Application Insights resource in the Azure portal.

Installing Necessary Packages

First, we'll install the required packages for OpenTelemetry and Azure Monitor. OpenTelemetry is an observability framework providing a standard way to generate and collect telemetry data, while Azure Monitor Exporter sends this data to Azure Application Insights. Run the following commands in your project:

npm install @opentelemetry/sdk @opentelemetry/resources @opentelemetry/semantic-conventions @opentelemetry/exporter-azure-monitor @opentelemetry/auto-instrumentations-node @opentelemetry/instrumentation @opentelemetry/instrumentation-fetch
yarn add @opentelemetry/sdk @opentelemetry/resources @opentelemetry/semantic-conventions @opentelemetry/exporter-azure-monitor @opentelemetry/auto-instrumentations-node @opentelemetry/instrumentation @opentelemetry/instrumentation-fetch

These packages include:

  • @opentelemetry/sdk: The OpenTelemetry SDK provides the core functionality for telemetry collection.
  • @opentelemetry/resources: This package helps define and attach resource attributes (like service name) to your telemetry.
  • @opentelemetry/semantic-conventions: Provides standard attribute names and values for telemetry data.
  • @opentelemetry/exporter-azure-monitor: The Azure Monitor Exporter sends telemetry data to Azure Application Insights.
  • @opentelemetry/auto-instrumentations-node: Automatically instruments Node.js applications to collect telemetry data without manual instrumentation.
  • @opentelemetry/instrumentation: Base package for creating custom instrumentation.
  • @opentelemetry/instrumentation-fetch: Instrument Fetch API to automatically track requests.

Configuring OpenTelemetry

Create a new file, for example, telemetry.ts, to configure OpenTelemetry. This file will initialize the OpenTelemetry SDK and set up the Azure Monitor Exporter.

// telemetry.ts
import { NodeSDK } from '@opentelemetry/sdk';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
import { AzureMonitorExporter } from '@opentelemetry/exporter-azure-monitor';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';

const resource = new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: 'nextjs-trpc-app',
    [SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0',
});

const exporter = new AzureMonitorExporter({
    connectionString: process.env.APPLICATIONINSIGHTS_CONNECTION_STRING,
});

const sdk = new NodeSDK({
    resource: resource,
    traceExporter: exporter,
    instrumentations: [
        getNodeAutoInstrumentations(),
        new FetchInstrumentation(),
    ],
});

registerInstrumentations({
    instrumentations: [
        new FetchInstrumentation(),
    ],
});


sdk.start()
    .then(() => console.log('Telemetry initialized'))
    .catch((error) => console.log('Error initializing telemetry', error));

export default sdk;

In this configuration:

  • We create a Resource to define service attributes like service name and version. This helps in identifying the source of telemetry data in Application Insights.
  • The AzureMonitorExporter is configured using the connection string from the environment variables. Make sure to set the APPLICATIONINSIGHTS_CONNECTION_STRING environment variable in your .env file or deployment settings.
  • We use getNodeAutoInstrumentations to automatically instrument common Node.js libraries, and FetchInstrumentation to monitor fetch API calls. This reduces the need for manual instrumentation.
  • The SDK is started, which initializes the telemetry pipeline. It's crucial to handle any errors during initialization to prevent issues with telemetry collection.

Initializing Telemetry in Your Application

To initialize telemetry when your application starts, import and initialize the SDK in your main entry point, such as _app.tsx or _app.js.

// _app.tsx or _app.js
import '../styles/globals.css';
import type { AppProps } from 'next/app';
import telemetry from '../telemetry';
import { useEffect } from 'react';

function MyApp({ Component, pageProps }: AppProps) {
    useEffect(() => {
        // Initialize telemetry
        telemetry;

        // Optional: Dispose of the SDK when the component unmounts
        return () => {
            telemetry.shutdown();
        };
    }, []);

    return <Component {...pageProps} />;
}

export default MyApp;

Initializing telemetry in _app.tsx ensures that it starts when your application loads. The useEffect hook is used to run the initialization code once when the component mounts. Additionally, the optional shutdown call ensures that resources are properly released when the component unmounts.

Setting User ID

Now, let's focus on setting the user ID for each request. This involves accessing the user session and setting the user context in OpenTelemetry spans. We'll need to modify both the frontend and backend to achieve this.

Accessing User Session

First, ensure you can access the user session information in both your Next.js frontend and tRPC backend. With Auth.js, you can use the getSession function to retrieve the session data. This data typically includes the user's ID, email, and other relevant information.

Frontend (Next.js)

In your Next.js components, you can use the useSession hook from next-auth/react to access the session.

// components/UserProfile.tsx
import { useSession } from 'next-auth/react';
import { useEffect } from 'react';
import { trace } from '@opentelemetry/api';

function UserProfile() {
    const { data: session } = useSession();

    useEffect(() => {
        if (session?.user?.id) {
            const tracer = trace.getTracer('nextjs-frontend');
            const span = tracer.startSpan('UserProfile Component');
            span.setAttribute('app.user.id', session.user.id);
            span.end();
        }
    }, [session]);

    if (session) {
        return (
            <div>
                <p>Welcome, {session.user?.name}!</p>
                {/* Other user profile content */}
            </div>
        );
    }
    return <p>Loading...</p>;
}

export default UserProfile;

In this example:

  • We use useSession to access the session data.
  • Inside a useEffect hook, we check if the session and user ID are available.
  • If available, we create a new OpenTelemetry span using trace.getTracer. This allows us to manually add attributes to the span.
  • We set the app.user.id attribute on the span with the user's ID from the session. This attribute will be sent to Application Insights.
  • The span is ended after setting the attribute. It's important to end spans to ensure proper telemetry data collection.

Backend (tRPC)

In your tRPC backend, you can access the session in your procedures using the context. The context should be set up to include the session data. Here’s how you can modify your tRPC context to include the session.

// src/server/context.ts
import * as trpc from '@trpc/server';
import * as trpcNext from '@trpc/server/adapters/next';
import { getSession } from 'next-auth/react';

export async function createContext(
    opts: trpcNext.CreateNextContextOptions
) {
    const session = await getSession(opts);
    return {
        session,
    };
}

type Context = trpc.inferAsyncReturnType<typeof createContext>;
export const createRouter = () => trpc.router<Context>();

export type { Context };

This code snippet shows how to:

  • Import necessary modules from tRPC and next-auth/react.
  • Create an asynchronous createContext function that retrieves the session using getSession.
  • Include the session in the context object, which will be available in your tRPC procedures.
  • Define a Context type for type safety.
  • Export a createRouter function to create tRPC routers with the context.

Setting User ID in tRPC Procedures

Now that the session is available in the context, you can access the user ID in your tRPC procedures and set it as an attribute on the OpenTelemetry span. This ensures that backend operations are also associated with the user.

// src/server/routers/example.ts
import { createRouter } from '../context';
import { trace } from '@opentelemetry/api';

export const exampleRouter = createRouter()
    .query('hello', {
        resolve({ ctx }) {
            const tracer = trace.getTracer('trpc-backend');
            const span = tracer.startSpan('example.hello');
            if (ctx.session?.user?.id) {
                span.setAttribute('app.user.id', ctx.session.user.id);
            }
            span.end();
            return {greeting: `Hello`};
        },
    });

In this example:

  • We import trace from @opentelemetry/api.
  • Inside the hello query procedure, we get the OpenTelemetry tracer and start a new span.
  • We check if the user ID is available in the session and set it as an attribute on the span.
  • The span is ended after setting the attribute.

Propagating User ID

To maintain the user context across the frontend and backend, it's essential to propagate the user ID in HTTP requests. This can be done by adding the user ID as a header in the request and extracting it in the backend.

Frontend (Adding User ID to Headers)

When making requests from the frontend to the backend (e.g., using fetch or tRPC's client), add the user ID to the request headers.

// utils/api.ts
import { getSession } from 'next-auth/react';

export async function fetchData(url: string, options: RequestInit = {}) {
    const session = await getSession();
    const userId = session?.user?.id;

    const headers = {
        ...options.headers,
        'x-user-id': userId || '',
    };

    const response = await fetch(url, {
        ...options,
        headers,
    });

    return response;
}

In this utility function:

  • We retrieve the session using getSession.
  • We extract the user ID from the session.
  • We add the user ID to the request headers using the x-user-id header.
  • The modified headers are included in the fetch request.

Backend (Extracting User ID from Headers)

In your tRPC middleware or context, extract the user ID from the request headers and set it on the active span. This ensures that the user context is maintained throughout the request lifecycle.

// src/server/context.ts
import * as trpc from '@trpc/server';
import * as trpcNext from '@trpc/server/adapters/next';
import { getSession } from 'next-auth/react';
import { IncomingHttpHeaders } from 'http';
import { trace, context, propagation } from '@opentelemetry/api';

interface ExtendedContext {
    session: Awaited<ReturnType<typeof getSession>> | null;
    userId: string | null;
}

export async function createContext(
    opts: trpcNext.CreateNextContextOptions
): Promise<ExtendedContext> {
    const session = await getSession(opts);
    const userId = getUserIdFromHeader(opts.req.headers);

    return {
        session,
        userId,
    };
}

function getUserIdFromHeader(headers: IncomingHttpHeaders): string | null {
    const userId = headers['x-user-id'] as string | undefined;
    return userId || null;
}

export type Context = trpc.inferAsyncReturnType<typeof createContext>;
export const createRouter = () => trpc.router<Context>();

export type { Context, ExtendedContext };

// src/server/routers/example.ts
import { createRouter } from '../context';
import { trace, context, propagation } from '@opentelemetry/api';

export const exampleRouter = createRouter()
    .middleware(async ({ ctx, next }) => {
        const userId = ctx.userId;
        if (userId) {
            const tracer = trace.getTracer('trpc-middleware');
            const span = tracer.startSpan('tRPC Middleware');
            span.setAttribute('app.user.id', userId);
            span.end();
        }
        return next();
    })
    .query('hello', {
        resolve({ ctx }) {
            const tracer = trace.getTracer('trpc-backend');
            const span = tracer.startSpan('example.hello');
            if (ctx.session?.user?.id) {
                span.setAttribute('app.user.id', ctx.session.user.id);
            }
            span.end();
            return { greeting: `Hello` };
        },
    });

Key points in this setup:

  • In src/server/context.ts, we define an ExtendedContext interface to include the userId.
  • The createContext function now extracts the x-user-id header using getUserIdFromHeader and includes it in the context.
  • In src/server/routers/example.ts, we add a middleware to extract the userId from the context and set it as an attribute on a new span. This ensures that the user ID is tracked for all tRPC procedures.

Viewing User Data in Azure Application Insights

Once you have set up user ID tracking, you can view the data in Azure Application Insights. Go to your Application Insights resource in the Azure portal and navigate to the Search tab. You can then filter events by the app.user.id attribute to see telemetry data associated with specific users.

You can also use the Users tool in Application Insights to gain insights into user behavior, such as the number of active users, their demographics, and their usage patterns. This provides a more aggregated view of user activity.

Conclusion

Setting user IDs in your Next.js and tRPC web application is essential for effective monitoring and debugging using Azure Application Insights. By integrating OpenTelemetry, you can capture and propagate user context across your frontend and backend, providing a comprehensive view of user interactions. This guide has walked you through the process of configuring OpenTelemetry, accessing user sessions, setting user IDs in spans, propagating user IDs in HTTP requests, and viewing user data in Azure Application Insights. Implementing these steps will empower you to gain deeper insights into your application's performance and user behavior, leading to better user experiences and more efficient troubleshooting.