Return

All tests / src/helpers aiCrawler.ts

100% Statements 37/37
83.33% Branches 5/6
100% Functions 5/5
100% Lines 28/28

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 11011x 11x   11x     11x     11x   11x                                 11x                 29x 29x 26x 180x               11x     10x 10x 4x                     11x                                       11x         6x 6x 6x   6x 6x         4x 1x   4x   2x 2x 2x      
import { z } from 'zod';
import { AI_CRAWLERS, type AiCrawler } from '@/constants/aiCrawlers';
 
const MEASUREMENT_PROTOCOL_URL = 'https://www.google-analytics.com/mp/collect';
// GA4 truncates event parameter values past 100 characters; cutting here keeps the stored value
// predictable instead of leaving it to GA4.
const MAX_PARAM_LENGTH = 100;
// The send runs after the response is handed back (waitUntil), so this bounds how long the edge
// function stays alive for a slow GA4, not how long the crawler waits.
const SEND_TIMEOUT_MS = 2000;
 
export const AI_CRAWLER_HIT_EVENT = 'ai_crawler_hit';
 
export type CrawlerAnalyticsConfig = {
    measurementId: string;
    apiSecret: string;
};
 
export type CrawlerHitPayload = {
    client_id: string;
    events: [
        {
            name: typeof AI_CRAWLER_HIT_EVENT;
            params: { bot: string; operator: string; purpose: string; path: string };
        },
    ];
};
 
const crawlerAnalyticsEnvSchema = z.object({
    GA_CRAWLER_MEASUREMENT_ID: z.string().regex(/^G-[A-Z0-9]+$/),
    GA_CRAWLER_API_SECRET: z.string().min(1),
});
 
/**
 * @description The documented AI crawler whose token appears in the User-Agent, if any.
 * Matching is case-insensitive because operators are not consistent about casing across versions.
 */
export const findAiCrawler = (userAgent: string | null | undefined): AiCrawler | undefined => {
    if (!userAgent) return undefined;
    const normalizedUserAgent = userAgent.toLowerCase();
    return AI_CRAWLERS.find((crawler) => normalizedUserAgent.includes(crawler.token.toLowerCase()));
};
 
/**
 * @description Crawler telemetry settings, or `undefined` when they are missing or malformed.
 * Missing settings disable sending; they are not an error, because previews and local builds run
 * without them.
 */
export const readCrawlerAnalyticsConfig = (
    env: Record<string, string | undefined>
): CrawlerAnalyticsConfig | undefined => {
    const parsed = crawlerAnalyticsEnvSchema.safeParse(env);
    if (!parsed.success) return undefined;
    return {
        measurementId: parsed.data.GA_CRAWLER_MEASUREMENT_ID,
        apiSecret: parsed.data.GA_CRAWLER_API_SECRET,
    };
};
 
/**
 * @description One Measurement Protocol event for one crawler request.
 * `client_id` is fixed per bot, so GA4 counts each bot as one "user" and no visitor identifier is
 * ever involved.
 */
export const buildCrawlerHitPayload = (crawler: AiCrawler, path: string): CrawlerHitPayload => ({
    client_id: `ai-crawler.${crawler.token.toLowerCase()}`,
    events: [
        {
            name: AI_CRAWLER_HIT_EVENT,
            params: {
                bot: crawler.token,
                operator: crawler.operator,
                purpose: crawler.purpose,
                path: path.slice(0, MAX_PARAM_LENGTH),
            },
        },
    ],
});
 
/**
 * @description Send one crawler hit to GA4. Never throws: a telemetry failure must not reach the
 * response the crawler receives.
 * @returns `true` when GA4 accepted the request.
 */
export const sendCrawlerHit = async (
    payload: CrawlerHitPayload,
    config: CrawlerAnalyticsConfig,
    fetchImplementation: typeof fetch = fetch
): Promise<boolean> => {
    const url = new URL(MEASUREMENT_PROTOCOL_URL);
    url.searchParams.set('measurement_id', config.measurementId);
    url.searchParams.set('api_secret', config.apiSecret);
 
    try {
        const response = await fetchImplementation(url, {
            method: 'POST',
            body: JSON.stringify(payload),
            signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
        });
        if (!response.ok) {
            console.warn(`[ai-crawler] GA4 rejected the hit for ${payload.events[0].params.bot}: ${response.status}`);
        }
        return response.ok;
    } catch (error) {
        const reason = error instanceof Error ? error.message : String(error);
        console.warn(`[ai-crawler] could not send the hit for ${payload.events[0].params.bot}: ${reason}`);
        return false;
    }
};