Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cdn-analysis/sql/f5/create-database.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CREATE DATABASE IF NOT EXISTS {{database}};
45 changes: 45 additions & 0 deletions src/cdn-analysis/sql/f5/create-raw-table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
-- F5 follow-up questions to verify once we receive a real customer log sample:
-- 1. What is the exact per-record timestamp field name, if one is present in the payload?
-- 2. Does the payload include a response content-type field we should prefer over URL heuristics?
-- 3. Is `domain` consistently populated, or do we need `authority` / `original_authority` as fallbacks?
-- 4. Is `time_to_first_downstream_tx_byte` consistently populated, and is it reported in seconds or milliseconds?
-- 5. Until then, year/month/day/hour from the S3 partition path is the source of truth for request time.
CREATE EXTERNAL TABLE IF NOT EXISTS {{database}}.{{rawTable}} (
req_path string,
req_params string,
user_agent string,
referer string,
rsp_code string,
domain string,
time_to_first_downstream_tx_byte string
)
PARTITIONED BY (
year string,
month string,
day string,
hour string
)
ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe'
WITH SERDEPROPERTIES (
'ignore.malformed.json' = 'true',
'dots.in.keys' = 'false',
'case.insensitive' = 'true'
)
LOCATION '{{rawLocation}}'
TBLPROPERTIES (
'schema_version' = '1',
'projection.enabled' = 'true',
'storage.location.template' = '{{rawLocation}}${year}/${month}/${day}/${hour}/',
'projection.year.type' = 'integer',
'projection.year.range' = '2024,2030',
'projection.month.type' = 'integer',
'projection.month.range' = '1,12',
'projection.month.digits' = '2',
'projection.day.type' = 'integer',
'projection.day.range' = '1,31',
'projection.day.digits' = '2',
'projection.hour.type' = 'integer',
'projection.hour.range' = '0,23',
'projection.hour.digits' = '2',
'has_encrypted_data' = 'false'
);
128 changes: 128 additions & 0 deletions src/cdn-analysis/sql/f5/insert-aggregated-referral.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
INSERT INTO {{database}}.{{aggregatedTable}}
WITH base AS (
SELECT
CASE
WHEN NULLIF(trim(req_params), '') IS NOT NULL AND substr(trim(req_params), 1, 1) = '?'
THEN CONCAT(req_path, trim(req_params))
WHEN NULLIF(trim(req_params), '') IS NOT NULL
THEN CONCAT(req_path, '?', trim(req_params))
ELSE req_path
END AS url,
NULLIF(trim(domain), '') AS host,
referer AS referer_raw,
user_agent
FROM {{database}}.{{rawTable}}
WHERE year = '{{year}}'
AND month = '{{month}}'
AND day = '{{day}}'
{{hourFilter}}
),

hosts AS (
SELECT DISTINCT host
FROM base
WHERE host IS NOT NULL
),

referrals_raw AS (
SELECT
url,
host,
try(url_extract_host(referer_raw)) AS referrer,
url_extract_parameter(url, 'utm_source') AS utm_source,
url_extract_parameter(url, 'utm_medium') AS utm_medium,

-- only the tracking_param, not the value (no PII)
-- for the tracking_param list, please refer to https://github.com/adobe/helix-rum-enhancer/blob/main/plugins/martech.js#L13-L22
-- list is limited to have feature parity with rum-enhancer
CASE
WHEN REGEXP_LIKE(
url,
'(?i)(gclid|gclsrc|wbraid|gbraid|dclid|msclkid|fb(?:cl|ad_|pxl_)id|tw(?:clid|src|term)|li_fat_id|epik|ttclid)'
) THEN 'paid'
WHEN REGEXP_LIKE(
url,
'(?i)(mc_(?:c|e)id|mkt_tok)'
) THEN 'email'
ELSE NULL
END AS tracking_param,

-- device bucket from User-Agent
CASE
WHEN regexp_like(coalesce(user_agent, ''),
'(?i)(mobi|iphone|ipod|ipad|android(?!.*tv)|windows phone|blackberry|bb10|opera mini|fennec|ucbrowser|silk|kindle|playbook|tablet)'
)
THEN 'mobile'
ELSE 'desktop'
END AS device,
'{{serviceProvider}}' AS cdn_provider,
CONCAT('{{year}}', '-', '{{month}}', '-', '{{day}}') as date

FROM base
WHERE
-- referral traffic definition
(
-- case 1: IF URL contains utm_source OR utm_medium
(
(url_extract_parameter(url, 'utm_source') IS NOT NULL AND url_extract_parameter(url, 'utm_source') <> '')
OR (url_extract_parameter(url, 'utm_medium') IS NOT NULL AND url_extract_parameter(url, 'utm_medium') <> '')
)

-- case 2: IF URL contains a known tracking param
OR REGEXP_LIKE(
url,
-- for the tracking_param list, please refer to https://github.com/adobe/helix-rum-enhancer/blob/main/plugins/martech.js#L13-L22
-- list is limited to have feature parity with rum-enhancer
'(?i)(gclid|gclsrc|wbraid|gbraid|dclid|msclkid|fbclid|fbad_id|fbpxl_id|twclid|twsrc|twterm|li_fat_id|epik|ttclid)'
)

-- case 3: IF cdn log contains external referrer (not one of first party hosts)
OR (
referer_raw IS NOT NULL AND try(url_extract_host(referer_raw)) NOT IN (SELECT host FROM hosts)
)
)

-- F5 access logs do not expose response content type, so use URL heuristics for page views.
AND (
NOT REGEXP_LIKE(url_extract_path(url), '(?i)\.(pdf|md|css|js|png|jpg|jpeg|gif|webp|php|svg|ico|woff|woff2|otf|ttf|eot|mp4|mp3|avi|mov|zip|tar|gz|json|xml|txt)$')
OR REGEXP_LIKE(url_extract_path(url), '(?i)(\.html?)$')
)

-- basic filtering on user_agent for bots, crawlers, programmatic clients
AND NOT REGEXP_LIKE(
COALESCE(user_agent, ''),
'(?i)(
bot|crawler|crawl|spider|slurp|archiver|fetch|monitor|pingdom|preview|scanner|scrapy|httpclient|urlgrabber|
ahrefs|semrush|mj12bot|dotbot|rogerbot|seznambot|linkdex|blexbot|screaming frog|
googlebot|bingbot|duckduckbot|baiduspider|yandex(bot|images)|sogou|exabot|
twitterbot|facebookexternalhit|linkedinbot|pinterest|quora link preview|whatsapp|telegrambot|discordbot|
curl|wget|python|httpie|okhttp|aiohttp|libwww-perl|lwp::simple|
java|go-http-client|php|ruby|perl|axios|node|synthetics|probe|ahc
)'
)
)

SELECT
url_extract_path(url) as url,
host,
referrer,
utm_source,
utm_medium,
tracking_param,
device,
date,
cdn_provider,
COALESCE(host, '') as x_forwarded_host,

-- Add partition columns as regular columns
'{{year}}' AS year,
'{{month}}' AS month,
'{{day}}' AS day,
'{{hour}}' AS hour
FROM referrals_raw
WHERE COALESCE(
NULLIF(utm_source, ''),
NULLIF(utm_medium, ''),
NULLIF(referrer, ''),
NULLIF(tracking_param, '')
) IS NOT NULL;
61 changes: 61 additions & 0 deletions src/cdn-analysis/sql/f5/insert-aggregated.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
INSERT INTO {{database}}.{{aggregatedTable}}
WITH base AS (
SELECT
CASE
WHEN NULLIF(trim(req_params), '') IS NOT NULL AND substr(trim(req_params), 1, 1) = '?'
THEN CONCAT(req_path, trim(req_params))
WHEN NULLIF(trim(req_params), '') IS NOT NULL
THEN CONCAT(req_path, '?', trim(req_params))
ELSE req_path
END AS url,
user_agent,
COALESCE(try_cast(NULLIF(trim(rsp_code), '') AS INTEGER), 0) AS status,
referer,
NULLIF(trim(domain), '') AS host,
COALESCE(try_cast(NULLIF(trim(time_to_first_downstream_tx_byte), '') AS DOUBLE), 0.0) * 1000 AS time_to_first_byte
FROM {{database}}.{{rawTable}}
WHERE year = '{{year}}'
AND month = '{{month}}'
AND day = '{{day}}'
{{hourFilter}}
)

SELECT
url_extract_path(url) AS url,
user_agent,
status,
try(url_extract_host(referer)) AS referer,
host,
time_to_first_byte,
COUNT(*) AS count,
'{{serviceProvider}}' AS cdn_provider,
COALESCE(host, '') as x_forwarded_host,

-- Add partition columns as regular columns
'{{year}}' AS year,
'{{month}}' AS month,
'{{day}}' AS day,
'{{hour}}' AS hour
FROM base
WHERE
-- match known LLM-related user-agents
REGEXP_LIKE(user_agent, '(?i)(ChatGPT|GPTBot|OAI-SearchBot|OAI-AdsBot|Perplexity|Claude|Anthropic|Gemini|Copilot|MistralAI-User|Google-NotebookLM|Google-?Agent|Google-Extended|Googlebot|bingbot|Amzn-User|^Google$)')

-- F5 access logs do not expose response content type, so use URL heuristics.
AND (
NOT REGEXP_LIKE(url_extract_path(url), '(?i)\.(css|js|png|jpg|jpeg|gif|webp|php|svg|ico|woff|woff2|otf|ttf|eot|mp4|mp3|avi|mov|zip|tar|gz|json|xml|txt)$')
OR REGEXP_LIKE(url_extract_path(url), '(?i)((\.html?|\.pdf|\.md|robots\.txt)$|sitemap)')
)

-- agentic and LLM-attributed traffic never has self-referer
AND NOT REGEXP_LIKE(COALESCE(referer, ''), '{{host}}')

GROUP BY
url_extract_path(url),
user_agent,
status,
try(url_extract_host(referer)),
host,
time_to_first_byte,
'{{serviceProvider}}',
COALESCE(host, '');
6 changes: 6 additions & 0 deletions src/utils/cdn-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const CDN_TYPES = {
CLOUDFRONT: 'cloudfront',
FRONTDOOR: 'frontdoor',
IMPERVA: 'imperva',
F5: 'f5',
OTHER: 'other',
};

Expand All @@ -41,6 +42,7 @@ export const SERVICE_PROVIDER_TYPES = {
BYOCDN_CLOUDFRONT: 'byocdn-cloudfront',
BYOCDN_FRONTDOOR: 'byocdn-frontdoor',
BYOCDN_IMPERVA: 'byocdn-imperva',
BYOCDN_F5: 'byocdn-f5',
BYOCDN_OTHER: 'byocdn-other',
AMS_CLOUDFRONT: 'ams-cloudfront',
AMS_FRONTDOOR: 'ams-frontdoor',
Expand All @@ -56,6 +58,7 @@ export const SERVICE_TO_CDN_MAPPING = {
[SERVICE_PROVIDER_TYPES.BYOCDN_CLOUDFRONT]: CDN_TYPES.CLOUDFRONT,
[SERVICE_PROVIDER_TYPES.BYOCDN_FRONTDOOR]: CDN_TYPES.FRONTDOOR,
[SERVICE_PROVIDER_TYPES.BYOCDN_IMPERVA]: CDN_TYPES.IMPERVA,
[SERVICE_PROVIDER_TYPES.BYOCDN_F5]: CDN_TYPES.F5,
[SERVICE_PROVIDER_TYPES.BYOCDN_OTHER]: CDN_TYPES.OTHER,
[SERVICE_PROVIDER_TYPES.AMS_CLOUDFRONT]: CDN_TYPES.CLOUDFRONT,
[SERVICE_PROVIDER_TYPES.AMS_FRONTDOOR]: CDN_TYPES.FRONTDOOR,
Expand Down Expand Up @@ -223,6 +226,9 @@ export async function determineCdnProvider(s3, bucket, prefix) {
if (hasText(rec.ClientRequestURI)) {
return CDN_TYPES.CLOUDFLARE;
}
if (hasText(rec.req_path)) {
return CDN_TYPES.F5;
}
} catch {
// fall-through intended
}
Expand Down
83 changes: 83 additions & 0 deletions test/audits/cdn-analysis/handler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ function createS3MockForCdnType(cdnType, options = {}) {
keyPath: `${orgId}/raw/byocdn-imperva/somefile.log`,
prefix: `${orgId}/raw/byocdn-imperva/`,
},
f5: {
logSample: '{"req_path": "/test", "req_params": "utm_source=ai", "domain": "example.com", "user_agent": "ChatGPT-User", "rsp_code": "200", "time_to_first_downstream_tx_byte": "0.123"}',
keyPath: isLegacy
? `raw/${year}/${month}/${day}/${hour}/file1.log`
: `${orgId}/raw/byocdn-f5/${year}/${month}/${day}/${hour}/file1.log`,
prefix: isLegacy
? 'raw/'
: `${orgId}/raw/byocdn-f5/`,
},
};

const config = cdnConfigs[cdnType];
Expand Down Expand Up @@ -109,6 +118,7 @@ function createS3MockForServiceProviders(serviceProviders, options = {}) {
'byocdn-akamai': `${year}/${month}/${day}/${hour}/`,
'byocdn-cloudflare': `${year}${month}${day}/`,
'byocdn-other': `${year}/${month}/${day}/`,
'byocdn-f5': `${year}/${month}/${day}/${hour}/`,
};

return (command) => {
Expand Down Expand Up @@ -290,6 +300,79 @@ describe('CDN Analysis Handler', () => {
expect(result22.auditResult.providers).to.be.an('array').with.length(0);
});

it('handles F5 processing with documented access log fields', async () => {
const mockedHandler = await loadMockedHandler('byocdn-f5');
const auditContext = {
year: 2025,
month: 6,
day: 15,
hour: 10,
};

context.s3Client.send.callsFake(
createS3MockForServiceProviders(['byocdn-f5']),
);

const result = await mockedHandler.cdnLogsAnalysisRunner(
'https://example.com',
context,
site,
auditContext,
);

expect(result.auditResult.providers).to.be.an('array').with.length(1);
expect(result.auditResult.providers[0]).to.include({
serviceProvider: 'byocdn-f5',
cdnType: 'f5',
});
expect(result.auditResult.providers[0].rawDataPath)
.to.include('/raw/byocdn-f5/2025/06/15/10/');

const executeCalls = context.athenaClient.execute.getCalls();
const aggregatedCall = executeCalls.find((call) => call.args[2]?.includes('Insert aggregated data for byocdn-f5'));
const referralCall = executeCalls.find((call) => call.args[2]?.includes('Insert aggregated referral data for byocdn-f5'));

expect(aggregatedCall.args[0]).to.include('req_path');
expect(aggregatedCall.args[0]).to.include('req_params');
expect(aggregatedCall.args[0]).to.include('time_to_first_downstream_tx_byte');
expect(aggregatedCall.args[0]).to.include("COALESCE(try_cast(NULLIF(trim(rsp_code), '') AS INTEGER), 0)");
expect(referralCall.args[0]).to.include('referer AS referer_raw');
expect(referralCall.args[0]).to.include("url_extract_parameter(url, 'utm_source')");
});

it('detects F5 legacy logs from req_path', async () => {
const mockedHandler = await loadMockedHandler('byocdn-f5');
const auditContext = {
year: 2025,
month: 6,
day: 15,
hour: 10,
};

context.s3Client.send.callsFake(
createS3MockForCdnType('f5', {
isLegacy: true,
year: '2025',
month: '06',
day: '15',
hour: '10',
}),
);

const result = await mockedHandler.cdnLogsAnalysisRunner(
'https://example.com',
context,
site,
auditContext,
);

expect(result.auditResult.providers).to.be.an('array').with.length(1);
expect(result.auditResult.providers[0]).to.include({
serviceProvider: 'f5',
cdnType: 'f5',
});
});

it('filters discovered service providers to the configured cdnProvider', async () => {
const mockedHandler = await loadMockedHandler('byocdn-akamai');
const auditContext = {
Expand Down
1 change: 1 addition & 0 deletions test/utils/cdn-utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('CDN Utils', () => {
CLOUDFRONT: 'cloudfront',
FRONTDOOR: 'frontdoor',
IMPERVA: 'imperva',
F5: 'f5',
OTHER: 'other',
});
});
Expand Down
Loading