Getting Started

Connect Google Cloud CDN

Forward Cloud CDN load balancer request logs to Honeylog through Cloud Logging and Pub/Sub.

This guide connects Google Cloud CDN on an external Application Load Balancer to Honeylog. It does not cover Media CDN. Requests follow this path: Cloud CDN → Cloud Logging → Pub/Sub topic → push subscription → Honeylog.

The Cloud CDN backend and public website must already be working. You need access to the Google project that owns the load balancer, permission to configure its logging and log sink, create a Pub/Sub topic and subscription, and grant Pub/Sub Publisher on the topic. You also need the site's Honeylog installation page, which supplies the domain, private ingestion secret and endpoint. The site and secret must be stored in the same Honeylog environment reached by the push endpoint. A site created only in a local preview dashboard cannot authenticate at the public Honeylog API. To test against the public API, use the site's personalized script from the public Honeylog dashboard. Keep the secret private.

#1. Confirm the request logs

In Google Cloud Console, open Logging → Logs Explorer and filter on resource.type="http_load_balancer". Visit a page through your site's public domain, then open its log entry. Confirm it has httpRequest.requestUrl, httpRequest.remoteIp, httpRequest.userAgent, httpRequest.requestMethod, resource.labels.project_id, and resource.labels.forwarding_rule_name. Confirm the request URL belongs to this site. The sink filter selects a forwarding rule, while the transform does not check the URL hostname.

For a backend service, enable logging in Load Balancing → your load balancer → Backend configuration. Set the sample rate to 1.0 (100%) if you want every request. Backend bucket logging is automatic. Google documents these settings in Cloud CDN logging.

Save the Google project ID and forwarding rule name on the private Honeylog installation page. The forwarding rule must belong to the website you are connecting. Cloud CDN can create an additional origin request log while serving one client request; this guide excludes those records using jsonPayload.parentInsertId. See Google's Cloud CDN log example and field descriptions.

#2. Create the Pub/Sub topic and push subscription

In the Google Cloud Console, select the project that owns your load balancer using the project picker at the top. Open the navigation menu (☰) and choose Pub/Sub → Topics. This is a separate Google Cloud service from Cloud Storage and Cloud CDN. Click Create topic (or open the Create topic page directly). If Google asks you to enable the Pub/Sub API, enable it for this project.

On the Create topic screen, use these choices (the Italian Console labels are in parentheses):

Option What to select
Topic ID (ID argomento) honeylog-cdn-requests
Add a default subscription (Aggiungi una sottoscrizione predefinita) Off
Use a schema (Utilizza uno schema) Off
Enable ingestion (Abilita importazione) Off
Enable message retention (Attiva conservazione messaggi) Off
Export to BigQuery or back up to Cloud Storage Off
Topic transforms (Trasformazioni) None; do not add a transform here
Encryption (Crittografia) Keep the selected Google-managed key

If you see Amazon Kinesis fields, clear Abilita importazione first. This integration uses a standard Pub/Sub topic: the Cloud Logging sink in step 4 will publish request logs to it. You do not need an Amazon Kinesis stream, ARN, AWS role, or service account for topic import. Click Create topic (Crea) at the bottom of the form.

On Pub/Sub → Topics, find the new topic, open its More actions (⋮) menu and choose Create subscription. Alternatively, open Pub/Sub → Subscriptions → Create subscription and select honeylog-cdn-requests from the Pub/Sub topic list. Enter honeylog-cdn-requests-push in Subscription ID and use these settings:

Setting Value
Delivery type Push
Endpoint URL The Honeylog ingestion endpoint on your private installation page, including /events or /api/events
Enable authentication Off
Payload unwrapping On
Write metadata On
Acknowledgment deadline 60 seconds
Message retention 7 days
Expiration Never expire
Retry policy Exponential backoff, minimum 10 seconds and maximum 600 seconds
Subscription filter (Filtro) Leave completely empty

Do not paste the filter from step 4 into the Pub/Sub Subscription filter (Filtro) field. Pub/Sub uses a different filter language. If the form says implicit conjunctions are not allowed, clear that field. Payload unwrapping sends the transformed JSON as the HTTP body. Write metadata sends its authentication attributes as HTTP headers. Both must be on. Honeylog authenticates the signed request using the site's ingestion secret, so leave Google push authentication off. Stay on the Create subscription form: add the subscription transform in step 3 before clicking Create.

#3. Add and test the transform

In the subscription configuration, open Transforms → Add a transform and choose JavaScript UDF. In Function name, replace the default functionName with honeylogNormalize (case-sensitive). Paste the complete script below into the JavaScript code field and leave the transform enabled. If validation says it cannot find functionName, that default was not replaced; the first line of the code defines honeylogNormalize. Replace YOUR_HONEYLOG_SITE_DOMAIN and YOUR_HONEYLOG_INGESTION_SECRET with the values from this site's private Honeylog installation page. Keep the quotation marks and do not disclose the personalized script. Put the transform on the subscription, not the topic, so the timestamp and signature are generated near push delivery.

This is the same JavaScript UDF used by the Firebase App Hosting integration. It includes page requests served from cache or the backend and drops static assets. The Cloud Logging sink filter in step 4 selects the forwarding rule and excludes extra origin requests. The UDF does not check the request hostname; use a forwarding rule dedicated to this site, or ensure the sink receives only this site's traffic. It preserves the original URL, client IP, user agent, method, timestamp and optional status, response size, referrer and latency.

#Complete script

function honeylogNormalize(message, metadata) {
  const site = "YOUR_HONEYLOG_SITE_DOMAIN";
  const secret = "YOUR_HONEYLOG_INGESTION_SECRET";

  const log = JSON.parse(message.data);
  const http = log.httpRequest;

  if (!http || !log.timestamp || !log.resource || !log.logName) {
    throw new Error("Expected a Google request log");
  }

  if (
    typeof http.remoteIp !== "string" || !http.remoteIp.trim() ||
    typeof http.userAgent !== "string" || !http.userAgent.trim() ||
    typeof http.requestMethod !== "string" ||
    !/^[A-Za-z]{1,16}$/.test(http.requestMethod)
  ) {
    throw new Error("Missing original visitor IP, user agent or request method");
  }

  const url = typeof http.requestUrl === "string" &&
    /^https?:\/\/([^/?#]+)(\/[^?#]*)?(?:[?#].*)?$/i.exec(http.requestUrl);

  if (!url) {
    throw new Error("Invalid request URL");
  }

  if (
    /\.(?:css|js|mjs|map|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|eot|mp4|webm|mp3|wav|pdf)$/i
      .test(url[2] || "/")
  ) {
    return null;
  }

  if (
    typeof log.timestamp !== "string" ||
    !Number.isFinite(Date.parse(log.timestamp))
  ) {
    throw new Error("Invalid log timestamp");
  }

  const event = {
    ip: http.remoteIp,
    ua: http.userAgent,
    n: "pageview",
    u: http.requestUrl,
    d: site,
    timestamp: log.timestamp.replace(
      /(\.\d{6})\d+(Z|[+-]\d{2}:\d{2})$/,
      "$1$2"
    ),
    method: http.requestMethod.toUpperCase()
  };

  if (http.referer) {
    event.r = http.referer;
  }

  if (http.status !== undefined) {
    if (
      !Number.isInteger(http.status) ||
      http.status < 100 ||
      http.status > 599
    ) {
      throw new Error("Invalid HTTP status");
    }

    event.status_code = http.status;
  }

  if (http.responseSize !== undefined) {
    const size = http.responseSize;

    if (
      (typeof size !== "number" && typeof size !== "string") ||
      !/^\d+$/.test(String(size)) ||
      !Number.isSafeInteger(Number(size))
    ) {
      throw new Error("Invalid responseSize");
    }

    event.bytes_sent = Number(size);
  }

  if (http.latency !== undefined) {
    if (
      typeof http.latency !== "string" ||
      !/^\d+(?:\.\d+)?s$/.test(http.latency)
    ) {
      throw new Error("Invalid latency");
    }

    const ms = Math.round(Number(http.latency.slice(0, -1)) * 1000);

    if (!Number.isSafeInteger(ms)) {
      throw new Error("Invalid latency");
    }

    event.response_time = ms;
  }

  // Signature timestamp: current time, not the log timestamp.
  const timestamp = String(Math.floor(Date.now() / 1000));

  // Distinguishes signatures from separate executions within the same second.
  // Stays outside the events and does not become a pageview property.
  const nonce =
    Date.now().toString(36) + "-" +
    Math.random().toString(36).slice(2) + "-" +
    Math.random().toString(36).slice(2);

  const body = JSON.stringify({
    events: [event],
    delivery_nonce: nonce
  });

  message.data = body;
  message.attributes = {
    "Content-Type": "application/json",
    "X-Honeylog-Site": site,
    "X-Honeylog-Timestamp": timestamp,
    "X-Honeylog-Signature": honeylogHmacSha256(
      secret,
      timestamp + "." + body
    )
  };

  return message;
}

function honeylogUtf8(text) {
  const out = [];

  for (let i = 0; i < text.length; i++) {
    let c = text.charCodeAt(i);

    if (c >= 0xd800 && c <= 0xdbff) {
      const next = text.charCodeAt(i + 1);

      if (next >= 0xdc00 && next <= 0xdfff) {
        c = 0x10000 + ((c - 0xd800) << 10) + next - 0xdc00;
        i++;
      } else {
        c = 0xfffd;
      }
    } else if (c >= 0xdc00 && c <= 0xdfff) {
      c = 0xfffd;
    }

    if (c < 0x80) {
      out.push(c);
    } else if (c < 0x800) {
      out.push(
        0xc0 | (c >>> 6),
        0x80 | (c & 63)
      );
    } else if (c < 0x10000) {
      out.push(
        0xe0 | (c >>> 12),
        0x80 | ((c >>> 6) & 63),
        0x80 | (c & 63)
      );
    } else {
      out.push(
        0xf0 | (c >>> 18),
        0x80 | ((c >>> 12) & 63),
        0x80 | ((c >>> 6) & 63),
        0x80 | (c & 63)
      );
    }
  }

  return out;
}

function honeylogSha256(bytes) {
  const k = [
    0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,
    0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
    0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,
    0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
    0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,
    0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
    0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,
    0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
    0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,
    0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
    0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,
    0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
    0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,
    0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
    0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,
    0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
  ];

  const h = [
    0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,
    0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19
  ];

  const data = bytes.slice();
  const low = (bytes.length * 8) >>> 0;
  const high = Math.floor(bytes.length / 0x20000000);

  data.push(0x80);

  while (data.length % 64 !== 56) {
    data.push(0);
  }

  for (let shift = 24; shift >= 0; shift -= 8) {
    data.push((high >>> shift) & 255);
  }

  for (let shift = 24; shift >= 0; shift -= 8) {
    data.push((low >>> shift) & 255);
  }

  const rotate = (x, n) => (x >>> n) | (x << (32 - n));
  const w = new Array(64);

  for (let offset = 0; offset < data.length; offset += 64) {
    for (let i = 0; i < 16; i++) {
      const j = offset + i * 4;

      w[i] = (
        (data[j] << 24) |
        (data[j + 1] << 16) |
        (data[j + 2] << 8) |
        data[j + 3]
      ) >>> 0;
    }

    for (let i = 16; i < 64; i++) {
      const x = w[i - 15];
      const y = w[i - 2];

      w[i] = (
        w[i - 16] +
        (rotate(x, 7) ^ rotate(x, 18) ^ (x >>> 3)) +
        w[i - 7] +
        (rotate(y, 17) ^ rotate(y, 19) ^ (y >>> 10))
      ) >>> 0;
    }

    let [a,b,c,d,e,f,g,z] = h;

    for (let i = 0; i < 64; i++) {
      const t1 = (
        z +
        (rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25)) +
        ((e & f) ^ (~e & g)) +
        k[i] +
        w[i]
      ) >>> 0;

      const t2 = (
        (rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22)) +
        ((a & b) ^ (a & c) ^ (b & c))
      ) >>> 0;

      z = g;
      g = f;
      f = e;
      e = (d + t1) >>> 0;
      d = c;
      c = b;
      b = a;
      a = (t1 + t2) >>> 0;
    }

    const state = [a,b,c,d,e,f,g,z];

    for (let i = 0; i < 8; i++) {
      h[i] = (h[i] + state[i]) >>> 0;
    }
  }

  const out = [];

  for (const word of h) {
    for (let shift = 24; shift >= 0; shift -= 8) {
      out.push((word >>> shift) & 255);
    }
  }

  return out;
}

function honeylogHmacSha256(secret, text) {
  let key = honeylogUtf8(secret);

  if (key.length > 64) {
    key = honeylogSha256(key);
  }

  const inner = [];
  const outer = [];

  for (let i = 0; i < 64; i++) {
    const b = key[i] || 0;
    inner.push(b ^ 0x36);
    outer.push(b ^ 0x5c);
  }

  return honeylogSha256(
    outer.concat(
      honeylogSha256(
        inner.concat(honeylogUtf8(text))
      )
    )
  ).map(b => b.toString(16).padStart(2, "0")).join("");
}

Click Validate, then Test transforms. Paste this sample into Input message as plain JSON, without Base64 encoding, and leave input attributes empty. It is an adapted test fixture based on Google's published Cloud CDN log structure: the page URL and identifiers are illustrative, not a captured log from your project. Replace example.com in httpRequest.requestUrl with your Honeylog site's domain. The test should return one event, a delivery_nonce, and the Content-Type, X-Honeylog-Site, X-Honeylog-Timestamp, and X-Honeylog-Signature attributes. After the test passes, close the Test transforms window, return to the Create subscription form, scroll to the bottom and click Create (Crea). This creates the push subscription with the transform; only then move to step 4.

{
  "insertId": "example-client-request",
  "jsonPayload": {
    "@type": "type.googleapis.com/google.cloud.loadbalancing.type.LoadBalancerLogEntry",
    "statusDetails": "response_from_cache"
  },
  "httpRequest": {
    "requestMethod": "GET",
    "requestUrl": "https://example.com/articles/cloud-cdn",
    "status": 200,
    "responseSize": "1024",
    "userAgent": "Mozilla/5.0 HoneylogSetupTest/1.0",
    "remoteIp": "203.0.113.10",
    "cacheHit": true,
    "cacheLookup": true
  },
  "resource": {
    "type": "http_load_balancer",
    "labels": {
      "project_id": "example-project",
      "forwarding_rule_name": "example-https-rule",
      "url_map_name": "example-url-map"
    }
  },
  "timestamp": "2026-10-21T12:00:00Z",
  "logName": "projects/example-project/logs/requests"
}

#4. Route Cloud Logging to the topic

Open Logging → Logs Router → Create sink. Name the sink honeylog-cdn-requests, choose Cloud Pub/Sub topic and select the topic from step 2. Paste this filter into Choose logs to include in sink → Build inclusion filter, replacing the project ID and forwarding rule with the values from your request log. This is a Cloud Logging sink filter; it does not go in the Pub/Sub subscription's Filtro field:

resource.type="http_load_balancer"
logName="projects/YOUR_PROJECT_ID/logs/requests"
resource.labels.forwarding_rule_name="YOUR_FORWARDING_RULE"
httpRequest:*
NOT jsonPayload.parentInsertId:*

Leave exclusion filters empty. The filter selects the load balancer rule and excludes extra origin requests. The transform does not check the website host, so the selected forwarding rule must serve only this Honeylog site. If the Console has not already granted publishing permission, copy the sink's Writer identity and grant it Pub/Sub Publisher on the topic under Pub/Sub → Topics → honeylog-cdn-requests → Permissions. Remove the serviceAccount: prefix when entering the identity as a principal.

#5. Verify delivery

Visit a new page through the website domain. Confirm its request appears in Logs Explorer. Then open Pub/Sub → Subscriptions → honeylog-cdn-requests-push → Metrics and check push requests and response codes. Honeylog accepts an event request with HTTP 202. Finally, look for the new visit in Honeylog. The sink routes only new logs; it does not backfill earlier visits.

If there is no log, check that logging is enabled for the backend service, the sample rate is 1.0, and no log exclusion removes the request. If the sink shows 0 B, make a fresh request after the sink's latest update and verify that the matching log's receiveTimestamp is later than that update. Older logs visible in Logs Explorer are not routed retroactively. In Logging → Log Router, open the sink's ⋮ → Troubleshoot sink action. You can also search Logs Explorer for sink configuration errors:

logName:"logging.googleapis.com%2Fsink_error"
resource.type="logging_sink"
resource.labels.name="honeylog-cdn-requests"

If there is a fresh matching log but the sink still routes 0 B, inspect the error and confirm that the sink's Writer identity has Pub/Sub Publisher on the destination topic. If the subscription's Push request count shows attempts, messages are reaching the subscription even if the sink volume still shows 0 B. If that chart shows invalid_argument, open it in Metrics Explorer and inspect both response_code and response_class for honeylog-cdn-requests-push. Separately, in Cloud Monitoring → Metrics Explorer, select Pub/Sub Subscription → Subscription message transform latency (subscription/message_transform_latencies) and group by status. A transform status of invalid_argument means the JavaScript UDF failed on at least one delivered log; test honeylogNormalize with the JSON from that real log in Logs Explorer. If the transform status is ok but push attempts fail, inspect the push response_class: remote_server_4xx means the endpoint returned an HTTP 4xx, while invalid means Pub/Sub could not process its response. Do not change the sink filter solely because of the push metric. If the transform output is null, check whether the request was for a static asset. For HTTP 401, check the endpoint, site domain and ingestion secret; for HTTP 429, check the ingestion rate limit and Pub/Sub retry metrics. Do not share the ingestion secret with support.