mirror of
https://github.com/tiennm99/miti-loki.git
synced 2026-10-11 03:13:40 +00:00
feat!: enrich Loki ingest with CF client info; move ip from label to metadata
Auto-inject low-cardinality labels (country, region, timezone) and
high-cardinality structured metadata (ip, user_agent, city, lat/long,
url, cf_ray, referer). Caller-supplied labels lose to auto on
collision; caller-supplied metadata wins on collision.
BREAKING CHANGE: `ip` is no longer a stream label. Rewrite
`{ip="..."}` LogQL queries as `{proxy="miti-loki"} | ip="..."`.
This commit is contained in:
1 parent
ebf1b61998
commit
186658a121
2 files changed
+69
-26
No files matched your search
@@ -28,16 +28,40 @@ Each entry is `{message, timestamp?, metadata?}`:
|
|||||||
|
|
||||||
- `message` (string, required) — log line.
|
- `message` (string, required) — log line.
|
||||||
- `timestamp` (string, optional) — Unix nanoseconds. Defaults to current time.
|
- `timestamp` (string, optional) — Unix nanoseconds. Defaults to current time.
|
||||||
- `metadata` (object, optional) — flat key-value pairs (no nested objects). Forwarded as Loki structured metadata.
|
- `metadata` (object, optional) — flat key-value pairs (no nested objects). Merged ON TOP of auto-injected metadata; caller wins on key collision.
|
||||||
|
|
||||||
### Stream labels
|
### Stream labels
|
||||||
|
|
||||||
URL query params become Loki stream labels. Label names must match `[a-zA-Z_:][a-zA-Z0-9_:]*` and cannot both start and end with `_` (reserved). Invalid labels return 400.
|
URL query params become Loki stream labels. Label names must match `[a-zA-Z_:][a-zA-Z0-9_:]*` and cannot both start and end with `_` (reserved). Invalid labels return 400.
|
||||||
|
|
||||||
Two labels are auto-injected (overwriting any caller-supplied values):
|
Auto-injected labels (overwrite caller-supplied values on collision):
|
||||||
|
|
||||||
- `proxy=miti-loki`
|
- `proxy=miti-loki`
|
||||||
- `ip=<client-ip>` (from `CF-Connecting-IP` / `X-Forwarded-For`)
|
- `country` — from `request.cf.country` (`unknown` if absent)
|
||||||
|
- `region` — from `request.cf.region` (`unknown` if absent)
|
||||||
|
- `timezone` — from `request.cf.timezone` (`unknown` if absent)
|
||||||
|
|
||||||
|
> **Breaking change (v2):** `ip` was previously a stream label. It is now per-entry structured metadata (see below). Rewrite `{ip="..."}` queries as `{proxy="miti-loki"} | ip="..."`.
|
||||||
|
|
||||||
|
### Auto-injected per-entry metadata
|
||||||
|
|
||||||
|
Every log entry is enriched with these structured-metadata fields. Caller-supplied `metadata.<key>` wins on collision; missing values become string `"unknown"`.
|
||||||
|
|
||||||
|
- `ip` — from `CF-Connecting-IP` / `X-Forwarded-For` / `X-Real-IP`
|
||||||
|
- `user_agent` — `User-Agent` header
|
||||||
|
- `city` — `request.cf.city`
|
||||||
|
- `latitude`, `longitude` — `request.cf.latitude` / `.longitude`
|
||||||
|
- `url` — full request URL (incl. query string)
|
||||||
|
- `cf_ray` — `CF-Ray` header (per-request trace ID for CF support)
|
||||||
|
- `referer` — `Referer` header
|
||||||
|
|
||||||
|
### LogQL examples
|
||||||
|
|
||||||
|
```logql
|
||||||
|
{proxy="miti-loki", country="VN"} # filter by low-cardinality label
|
||||||
|
{proxy="miti-loki"} | ip="1.2.3.4" # filter by structured metadata
|
||||||
|
{proxy="miti-loki", app="demo"} | user_agent=~"curl/.*"
|
||||||
|
```
|
||||||
|
|
||||||
### Errors
|
### Errors
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,36 @@ function validateLabelName(name) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Low-cardinality CF-derived fields → stream labels.
|
||||||
|
// Caller-supplied params with these names are overwritten by spread in handleRequest.
|
||||||
|
function collectAutoLabels(request) {
|
||||||
|
return {
|
||||||
|
proxy: 'miti-loki',
|
||||||
|
country: request.cf?.country || 'unknown',
|
||||||
|
region: request.cf?.region || 'unknown',
|
||||||
|
timezone: request.cf?.timezone || 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// High-cardinality fields → per-entry structured metadata.
|
||||||
|
// All values coerced to strings (Loki structured metadata requires string scalars).
|
||||||
|
function collectAutoMetadata(request) {
|
||||||
|
const ip = request.headers.get('CF-Connecting-IP') ||
|
||||||
|
request.headers.get('X-Forwarded-For') ||
|
||||||
|
request.headers.get('X-Real-IP') ||
|
||||||
|
'unknown';
|
||||||
|
return {
|
||||||
|
ip,
|
||||||
|
user_agent: request.headers.get('User-Agent') || 'unknown',
|
||||||
|
city: request.cf?.city || 'unknown',
|
||||||
|
latitude: String(request.cf?.latitude ?? 'unknown'),
|
||||||
|
longitude: String(request.cf?.longitude ?? 'unknown'),
|
||||||
|
url: request.url || 'unknown',
|
||||||
|
cf_ray: request.headers.get('CF-Ray') || 'unknown',
|
||||||
|
referer: request.headers.get('Referer') || 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRequest(request, env) {
|
async function handleRequest(request, env) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
@@ -74,6 +104,9 @@ async function handleRequest(request, env) {
|
|||||||
// Convert single object to array
|
// Convert single object to array
|
||||||
const logs = Array.isArray(data) ? data : [data];
|
const logs = Array.isArray(data) ? data : [data];
|
||||||
|
|
||||||
|
// Build auto-injected metadata once per request — same for every entry in a batch.
|
||||||
|
const autoMeta = collectAutoMetadata(request);
|
||||||
|
|
||||||
// Validate and build log values array
|
// Validate and build log values array
|
||||||
const values = [];
|
const values = [];
|
||||||
for (const entry of logs) {
|
for (const entry of logs) {
|
||||||
@@ -90,9 +123,9 @@ async function handleRequest(request, env) {
|
|||||||
// Get timestamp (use provided or current time in nanoseconds)
|
// Get timestamp (use provided or current time in nanoseconds)
|
||||||
const timestamp = entry.timestamp || (Date.now() * 1000000).toString();
|
const timestamp = entry.timestamp || (Date.now() * 1000000).toString();
|
||||||
|
|
||||||
// Validate and process metadata if present
|
// Validate caller-supplied metadata if present
|
||||||
|
let callerMeta = {};
|
||||||
if (entry.metadata) {
|
if (entry.metadata) {
|
||||||
// Check that metadata is an object
|
|
||||||
if (typeof entry.metadata !== 'object' || Array.isArray(entry.metadata)) {
|
if (typeof entry.metadata !== 'object' || Array.isArray(entry.metadata)) {
|
||||||
return new Response('Metadata must be an object', {
|
return new Response('Metadata must be an object', {
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -101,8 +134,6 @@ async function handleRequest(request, env) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate metadata is flat (no nested objects)
|
|
||||||
for (const [key, value] of Object.entries(entry.metadata)) {
|
for (const [key, value] of Object.entries(entry.metadata)) {
|
||||||
if (typeof value === 'object' && value !== null) {
|
if (typeof value === 'object' && value !== null) {
|
||||||
return new Response(`Metadata field "${key}" contains nested object. Only flat key-value pairs are allowed.`, {
|
return new Response(`Metadata field "${key}" contains nested object. Only flat key-value pairs are allowed.`, {
|
||||||
@@ -113,24 +144,15 @@ async function handleRequest(request, env) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
callerMeta = entry.metadata;
|
||||||
// Include metadata if it has properties
|
|
||||||
if (Object.keys(entry.metadata).length > 0) {
|
|
||||||
values.push([timestamp.toString(), entry.message, entry.metadata]);
|
|
||||||
} else {
|
|
||||||
values.push([timestamp.toString(), entry.message]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
values.push([timestamp.toString(), entry.message]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Caller metadata wins on key collision with auto-injected metadata.
|
||||||
|
const entryMetadata = { ...autoMeta, ...callerMeta };
|
||||||
|
values.push([timestamp.toString(), entry.message, entryMetadata]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get client IP from Cloudflare headers
|
// Build stream labels: caller-supplied first, auto-injected last (auto wins).
|
||||||
const clientIP = request.headers.get('CF-Connecting-IP') ||
|
|
||||||
request.headers.get('X-Forwarded-For') ||
|
|
||||||
'unknown';
|
|
||||||
|
|
||||||
// Build stream labels from URL parameters
|
|
||||||
const stream = {};
|
const stream = {};
|
||||||
for (const [key, value] of url.searchParams.entries()) {
|
for (const [key, value] of url.searchParams.entries()) {
|
||||||
if (!validateLabelName(key)) {
|
if (!validateLabelName(key)) {
|
||||||
@@ -143,10 +165,7 @@ async function handleRequest(request, env) {
|
|||||||
}
|
}
|
||||||
stream[key] = value;
|
stream[key] = value;
|
||||||
}
|
}
|
||||||
|
Object.assign(stream, collectAutoLabels(request));
|
||||||
// Add/overwrite proxy and ip labels
|
|
||||||
stream.proxy = 'miti-loki';
|
|
||||||
stream.ip = clientIP;
|
|
||||||
|
|
||||||
// Create Loki payload
|
// Create Loki payload
|
||||||
const lokiPayload = {
|
const lokiPayload = {
|
||||||
|
|||||||
Reference in new issue
Block a user