What is Vector
Vector is a tool that takes metrics/events/logs data, routes and manipulates them and sends them to sinks. In that it is similar to software like Logstash or Riemann (and can mostly replace them)
It is made to serve as middleman in observability pipeline, applying any required transformations on the incoming streams of logs, metrics or traces
Why would I need such a tool?
The range is very wide due to how generic it is but the use cases I found particularly useful:
- parsing system logs into fields - while it is best done on the source devices it is not always possible (say logs from switches/routers)
- splitting input stream into different targets - for example sending logs related to mail or network operation to a different elasticsearch index, or saving audit logs in a different store
- garnishing entries with additional data - like adding GeoIP data to firewall logs so the end application can do geolocation based queries directly on database
- adding/dropping/reformatting/editing fields - sometimes you might not care about all fields, want them merged or split, or anonymized
That sounds like Logstash. Why would I use it instead of Logstash?
Ops-wise it has smaller memory footprint and better performance.
Language-wise, explicit source -> destination links instead of treating everything as one big pipeline like in Logstash are more readable.
It also handles metrics natively while in Logstash it is limited.
Vector basics
Sources, sinks, and transforms
In Vector configuration language flow of data is basically a DAG from source thru zero or more transforms to the sink.
1sources: 2 vector_metrics: 3 type: internal_metrics 4 scrape_interval_secs: 10 5 syslog_remote: 6 type: socket 7 address: "0.0.0.0:3515" 8 mode: tcp 9 max_length: 102400
In this example we gather internal metrics under vector_metrics source and receive JSON-encoded syslog entries. We will decode them in the next step.
1transforms: 2 parse_syslog: 3 type: remap 4 inputs: ["syslog_remote"] 5 reroute_dropped: true 6 source: | 7 v, err = parse_json(string!(.message)) 8 if err != null { 9 abort "bad json: " + err 10 } 11 . = v
This is done so we have fine-grained control on the error handling.
The dropped messages are routed to parse_syslog.dropped if reroute_dropped: true is set, that can be used to debug dropped events.
Abort passes unmodified event so any modifications are lost.
If you are 100% sure JSON will always be correct you can just add:
1 decoding: 2 codec: json
to the source config to decode it directly.
You can then display it in console for debug (make sure it isn't reading its own logs on input else it will re-emit its own logs in a loop!):
1sinks: 2 console_out: 3 type: console 4 inputs: 5 - parse_syslog 6 encoding: 7 codec: "json" 8 json: 9 pretty: true
and see messages emitted on stderr/systemd logs, example being a firewall log from Linux machine:
1 vector[362335]: { 2 vector[362335]: "@timestamp": "2026-07-15T19:52:49.760181+02:00", 3 vector[362335]: "facility": "kern", 4 vector[362335]: "fromhost": "dc1-example-vpn1", 5 vector[362335]: "message": " [17998932.400138] vsa[jkowalski-out]: IN=tun2 OUT=eth0 MAC= SRC=192.168.1.160 DST=10.0.1.2 LEN=64 TOS=0x00 PREC=0x00 TTL=63 SPT=55218 DPT=3128 WINDOW=65535 RES=0x00 CWR ECE SYN URGP=0 ", 6 vector[362335]: "priority": "info", 7 vector[362335]: "program": "iptables", 8 vector[362335]: "route": "iptables" 9 vector[362335]: }
or send it to Elasticsearch for further viewing/processing
1sinks: 2 syslog: 3 type: elasticsearch 4 inputs: 5 - parse_syslog 6 mode: bulk 7 bulk: 8 index: syslog-%Y.%m.%d 9 endpoints: 10 - http://127.0.0.1:9200
Enrichment tables
Those are basically a way to look up a value based on another one. Example for GeoIP:
1 2enrichment_tables: 3 geoip_city: 4 type: geoip 5 path: "/var/lib/geoip/GeoLite2-City.mmdb" 6 geoip_asn: 7 type: geoip 8 path: "/var/lib/geoip/GeoLite2-ASN.mmdb"
then, in transform, you feed it the IP to resolve
1 geo_city, err = get_enrichment_table_record("geoip_city", { "ip": .src_ip }) 2 if err == null { 3 .geoip = geo_city 4 del(.geoip.timezone) 5 geo_asn, err = get_enrichment_table_record("geoip_asn", { "ip": .src_ip }) 6 if err == null { 7 .as.number = geo_asn.autonomous_system_number 8 .as.org = geo_asn.autonomous_system_organization 9 .as.net = geo_asn.network 10 } 11 }
This example takes src_ip field (assumed existence; if the field doesn't exist it is better to put whole block in if case for that) and:
- looks up the GeoIP city data
- removes timezone (we don't care about keeping that data as it is easy to look up)
- looks up the GeoIP ASN data
- cherry picks the fields we want in database (AS number/org and network)
Inputs and routing
Every source and transform produces one or more outputs that can then be used in another transform or sink in inputs fields.
For example the opentelemetry source provides .logs/.traces/.metrics and transforms like route can split the incoming stream into multiple outputs:
1transforms: 2 route: 3 inputs: ["parse_syslog"] 4 type: route 5 route: 6 ok: '!exists(.route)' 7 iptables: '.route == "iptables"' 8 dhcpd: '.route == "dhcpd"' 9 mail: '.route == "mail"'
Note that route type routes non-exclusively - if an event matches more than one route, it is sent to multiple output streams - for exclusive "one event one route" behavior there is exclusive_route transform.
The route transform also emits _unmatched output for events that did not match any route.
Another output worth noting is dropped, which emits events that were aborted in a transform when reroute_dropped: true is set (note the opposite defaults: reroute_unmatched defaults to true, reroute_dropped to false).
Example config
The parts of it are explained below:
Anonymizing Claude Code AI traces
Imagine your company wants to track the Claude Code usage but without giving management too intrusive a view into users' usage patterns. What we need to do is to take a set of fields that identify a user and anonymize them.
Note that it CANNOT be a simple hash (which would be easy to do on many OTLP-compatible collectors), because if it is a simple hash it is trivial to do email -> hash map if you just know someone's corporate email. We have to use a cryptographic function with a secret to map it into something that can't be trivially reverted. So we use HMAC with a private key.
First, basic setup of sources:
1api: 2 enabled: true 3 address: "127.0.0.1:8686" 4secret: 5 file: 6 type: file 7 path: /etc/vector/secrets.json # {"anon_key": "anonymization secret"} 8 9sources: 10 vector_metrics: # keep the internal metrics available for scrape 11 type: internal_metrics 12 scrape_interval_secs: 10 13 otel_in: 14 type: opentelemetry 15 grpc: 16 address: 0.0.0.0:14317 17 http: 18 address: 0.0.0.0:14318 19 # that means "decode in OTLP native format, not in vector native format, we need it as 20 # we will be pushing it to OTLP-compatible store 21 use_otlp_decoding: 22 logs: true 23 metrics: true 24 traces: true
The only thing worth noting here is usage of secret; an environment variable could be used but that is not reloaded when vector does graceful config reload, while secret store is. We want that feature because we might also want to rotate the secret so as not to connect user ID with a given hash permanently.
Then, the tricky step. OTLP format is a bit verbose, for example instead of having key -> value map, it puts resource attributes into array (despite spec saying they are unique!) and doesn't put value directly but has type -> value indirection:
1 "resource": { 2 "attributes": [ 3 { 4 "key": "service.name", 5 "value": { 6 "stringValue": "my.service" 7 } 8 } 9 ]
so we have to do some looping to actually find and remove the value. We also need to do a separate operation for traces/metrics/logs due to different field naming and the fact that the OTLP sink is a pretty thin wrapper that outputs data directly to a given URL (so we need one per type). Example for metrics:
1transforms: 2 anonymize_metrics: 3 type: remap 4 inputs: 5 - otel_in.metrics # metrics arrive on the .metrics output as log events 6 source: | 7 key = "SECRET[file.anon_key]" 8 # list of keys we want to anonymize 9 anon_keys = ["user.email", "user.id", "user.account_id", "user.account_uuid"] 10 # and ones we don't care about 11 del_keys = ["os.version"] 12 13 # due to language being compiled we can't get too dynamic with programming here 14 .resourceMetrics = map_values(array!(.resourceMetrics)) -> |rm| { 15 r = object!(rm) 16 if exists(r.resource.attributes) { 17 r.resource.attributes = filter(array!(r.resource.attributes)) -> |_index, attr| { 18 !(is_object(attr) && includes(del_keys, attr.key)) 19 } 20 } 21 r 22 } 23 24 # now we modify the fields 25 . = map_values(., recursive: true) -> |value| { 26 if is_object(value) && includes(anon_keys, value.key) { 27 attr = object!(value) 28 attr.value.stringValue = 29 slice!(encode_base16(hmac(string!(attr.value.stringValue), key, algorithm: "SHA-256")), 0, 10) 30 attr 31 } else { 32 value 33 } 34 }
At this point we have anonymize_metrics transform that takes and emits OTLP-compatible structure, so we only need to send it:
1 otel_out_metrics: 2 type: opentelemetry 3 inputs: 4 - anonymize_metrics 5 protocol: 6 type: http 7 uri: "http://127.0.0.1:4318/v1/metrics" 8 method: post 9 encoding: 10 codec: otlp
and repeat the exercise for remaining 2 types (logs/traces). All is in the provided example file above.
Converting OTLP metrics to Prometheus ones
This is very simple. All you need to do is to use OTEL source in native mode:
1sources: 2 otel_raw_in: # loopback in for prometheus conversion 3 type: opentelemetry 4 use_otlp_decoding: false # this makes it use Vector format for outgoing data instead of OTLP native decoding 5 grpc: 6 address: 127.0.0.1:14327 7 http: 8 address: 127.0.0.1:14328
and just send it to prometheus compatible sink:
1 prometheus_out: 2 type: prometheus_remote_write 3 inputs: 4 - otel_raw_in.metrics 5 endpoint: https://promwriter-api.example.com/api/v1/write 6 expire_metrics_secs: 36000 7 healthcheck: # you might need to turn it off if you use backend that doesn't have Prometheus healthcheck endpoint, like VictoriaMetrics 8 enabled: false 9 batch: 10 max_events: 1000 11 timeout_secs: 5
Vector-native format is WAY easier to anonymize (you just have key-value in that format), but if you have the setup above for anonymization you can loop it back to itself so the same vector instance is doing double duty.
1 otel_out_loop: 2 type: opentelemetry 3 inputs: 4 - anonymize_metrics 5 protocol: 6 type: http 7 uri: "http://127.0.0.1:14328/v1/metrics" 8 method: post 9 encoding: 10 codec: otlp
This is mostly needed as there is no native function to parse it to/from OTLP format.
Plugging Claude into it
For any production workload put the whole thing behind loadbalancer preferable also with some form of auth (you can use OTEL_EXPORTER_OTLP_HEADERS for simple auth in Claude Code), required settings in ~/.claude/settings.json look like this:
1{ 2 "env": { 3 "CLAUDE_CODE_ENABLE_TELEMETRY": "1", 4 "CLAUDE_CODE_ENHANCED_TELEMETRY_BETA": "1", 5 "OTEL_LOG_TOOL_DETAILS": "0", 6 "OTEL_LOG_USER_PROMPTS": "0", 7 "OTEL_TRACES_EXPORTER": "otlp", 8 "OTEL_METRICS_EXPORTER": "otlp", 9 "OTEL_LOGS_EXPORTER": "otlp", 10 "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf", 11 "OTEL_EXPORTER_OTLP_ENDPOINT": "http://vector.example.com:14318", 12 "OTEL_EXPORTER_OTLP_HEADERS": "x-api-key=auth", 13 "OTEL_METRIC_EXPORT_INTERVAL": "60000" 14 } 15}
or you can use env with it
