| // Licensed to the Apache Software Foundation (ASF) under one | |
| // or more contributor license agreements. See the NOTICE file | |
| // distributed with this work for additional information | |
| // regarding copyright ownership. The ASF licenses this file | |
| // to you under the Apache License, Version 2.0 (the | |
| // "License"); you may not use this file except in compliance | |
| // with the License. You may obtain a copy of the License at | |
| // | |
| // http://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, | |
| // software distributed under the License is distributed on an | |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| // KIND, either express or implied. See the License for the | |
| // specific language governing permissions and limitations | |
| // under the License. | |
| use std::ops::Range; | |
| use std::sync::Arc; | |
| use arrow::array::{ | |
| Decimal128Builder, Int32Builder, StringBuilder, StringDictionaryBuilder, | |
| TimestampNanosecondBuilder, UInt16Builder, | |
| }; | |
| use arrow::datatypes::{DataType, Field, Int32Type, Schema, SchemaRef, TimeUnit}; | |
| use arrow::record_batch::RecordBatch; | |
| use rand::prelude::StdRng; | |
| use rand::{Rng, SeedableRng}; | |
| #[derive(Debug, Clone)] | |
| struct GeneratorOptions { | |
| row_limit: usize, | |
| pods_per_host: Range<usize>, | |
| containers_per_pod: Range<usize>, | |
| entries_per_container: Range<usize>, | |
| include_nulls: bool, | |
| } | |
| impl Default for GeneratorOptions { | |
| fn default() -> Self { | |
| Self { | |
| row_limit: usize::MAX, | |
| pods_per_host: 1..15, | |
| containers_per_pod: 1..3, | |
| entries_per_container: 1024..8192, | |
| include_nulls: false, | |
| } | |
| } | |
| } | |
| /// Creates access log like entries | |
| #[derive(Default)] | |
| struct BatchBuilder { | |
| service: StringDictionaryBuilder<Int32Type>, | |
| host: StringDictionaryBuilder<Int32Type>, | |
| pod: StringDictionaryBuilder<Int32Type>, | |
| container: StringDictionaryBuilder<Int32Type>, | |
| image: StringDictionaryBuilder<Int32Type>, | |
| time: TimestampNanosecondBuilder, | |
| client_addr: StringBuilder, | |
| request_duration: Int32Builder, | |
| request_user_agent: StringBuilder, | |
| request_method: StringBuilder, | |
| request_host: StringBuilder, | |
| request_bytes: Int32Builder, | |
| response_bytes: Int32Builder, | |
| response_status: UInt16Builder, | |
| prices_status: Decimal128Builder, | |
| options: GeneratorOptions, | |
| row_count: usize, | |
| } | |
| impl BatchBuilder { | |
| fn schema() -> SchemaRef { | |
| let utf8_dict = | |
| || DataType::Dictionary(Box::new(DataType::Int32), Box::new(DataType::Utf8)); | |
| Arc::new(Schema::new(vec![ | |
| Field::new("service", utf8_dict(), true), | |
| Field::new("host", utf8_dict(), false), | |
| Field::new("pod", utf8_dict(), false), | |
| Field::new("container", utf8_dict(), false), | |
| Field::new("image", utf8_dict(), false), | |
| Field::new( | |
| "time", | |
| DataType::Timestamp(TimeUnit::Nanosecond, None), | |
| false, | |
| ), | |
| Field::new("client_addr", DataType::Utf8, true), | |
| Field::new("request_duration_ns", DataType::Int32, false), | |
| Field::new("request_user_agent", DataType::Utf8, true), | |
| Field::new("request_method", DataType::Utf8, true), | |
| Field::new("request_host", DataType::Utf8, true), | |
| Field::new("request_bytes", DataType::Int32, true), | |
| Field::new("response_bytes", DataType::Int32, true), | |
| Field::new("response_status", DataType::UInt16, false), | |
| Field::new("decimal_price", DataType::Decimal128(38, 0), false), | |
| ])) | |
| } | |
| fn is_finished(&self) -> bool { | |
| self.options.row_limit <= self.row_count | |
| } | |
| fn append(&mut self, rng: &mut StdRng, host: &str, service: &str) { | |
| let num_pods = rng.random_range(self.options.pods_per_host.clone()); | |
| let pods = generate_sorted_strings(rng, num_pods, 30..40); | |
| for pod in pods { | |
| let num_containers = | |
| rng.random_range(self.options.containers_per_pod.clone()); | |
| for container_idx in 0..num_containers { | |
| let container = format!("{service}_container_{container_idx}"); | |
| let image = format!( | |
| "{container}@sha256:30375999bf03beec2187843017b10c9e88d8b1a91615df4eb6350fb39472edd9" | |
| ); | |
| let num_entries = | |
| rng.random_range(self.options.entries_per_container.clone()); | |
| for i in 0..num_entries { | |
| if self.is_finished() { | |
| return; | |
| } | |
| let time = i as i64 * 1024; | |
| self.append_row(rng, host, &pod, service, &container, &image, time); | |
| } | |
| } | |
| } | |
| } | |
| #[expect(clippy::too_many_arguments)] | |
| fn append_row( | |
| &mut self, | |
| rng: &mut StdRng, | |
| host: &str, | |
| pod: &str, | |
| service: &str, | |
| container: &str, | |
| image: &str, | |
| time: i64, | |
| ) { | |
| self.row_count += 1; | |
| let methods = &["GET", "PUT", "POST", "HEAD", "PATCH", "DELETE"]; | |
| let status = &[200, 204, 400, 503, 403]; | |
| self.service.append(service).unwrap(); | |
| self.host.append(host).unwrap(); | |
| self.pod.append(pod).unwrap(); | |
| self.container.append(container).unwrap(); | |
| self.image.append(image).unwrap(); | |
| self.time.append_value(time); | |
| if self.options.include_nulls { | |
| // Append a null value if the option is set | |
| // Use both "NULL" as a string and a null value | |
| if rng.random_bool(0.5) { | |
| self.client_addr.append_null(); | |
| } else { | |
| self.client_addr.append_value("NULL"); | |
| } | |
| } else { | |
| self.client_addr.append_value(format!( | |
| "{}.{}.{}.{}", | |
| rng.random::<u8>(), | |
| rng.random::<u8>(), | |
| rng.random::<u8>(), | |
| rng.random::<u8>() | |
| )); | |
| } | |
| self.request_duration.append_value(rng.random()); | |
| self.request_user_agent | |
| .append_value(random_string(rng, 20..100)); | |
| self.request_method | |
| .append_value(methods[rng.random_range(0..methods.len())]); | |
| self.request_host | |
| .append_value(format!("https://{service}.mydomain.com")); | |
| self.request_bytes | |
| .append_option(rng.random_bool(0.9).then(|| rng.random())); | |
| self.response_bytes | |
| .append_option(rng.random_bool(0.9).then(|| rng.random())); | |
| self.response_status | |
| .append_value(status[rng.random_range(0..status.len())]); | |
| self.prices_status.append_value(self.row_count as i128); | |
| } | |
| fn finish(mut self, schema: SchemaRef) -> RecordBatch { | |
| RecordBatch::try_new( | |
| schema, | |
| vec![ | |
| Arc::new(self.service.finish()), | |
| Arc::new(self.host.finish()), | |
| Arc::new(self.pod.finish()), | |
| Arc::new(self.container.finish()), | |
| Arc::new(self.image.finish()), | |
| Arc::new(self.time.finish()), | |
| Arc::new(self.client_addr.finish()), | |
| Arc::new(self.request_duration.finish()), | |
| Arc::new(self.request_user_agent.finish()), | |
| Arc::new(self.request_method.finish()), | |
| Arc::new(self.request_host.finish()), | |
| Arc::new(self.request_bytes.finish()), | |
| Arc::new(self.response_bytes.finish()), | |
| Arc::new(self.response_status.finish()), | |
| Arc::new( | |
| self.prices_status | |
| .finish() | |
| .with_precision_and_scale(38, 0) | |
| .unwrap(), | |
| ), | |
| ], | |
| ) | |
| .unwrap() | |
| } | |
| } | |
| fn random_string(rng: &mut StdRng, len_range: Range<usize>) -> String { | |
| let len = rng.random_range(len_range); | |
| (0..len) | |
| .map(|_| rng.random_range(b'a'..=b'z') as char) | |
| .collect::<String>() | |
| } | |
| fn generate_sorted_strings( | |
| rng: &mut StdRng, | |
| count: usize, | |
| str_len: Range<usize>, | |
| ) -> Vec<String> { | |
| let mut strings: Vec<_> = (0..count) | |
| .map(|_| random_string(rng, str_len.clone())) | |
| .collect(); | |
| strings.sort_unstable(); | |
| strings | |
| } | |
| /// Iterator that generates sorted, [`RecordBatch`]es with randomly generated data with | |
| /// an access log style schema for tracing or monitoring type | |
| /// use cases. | |
| /// | |
| /// This is useful for writing tests queries on such data | |
| /// | |
| /// Here are the columns with example data: | |
| /// | |
| /// ```text | |
| /// service: 'backend' | |
| /// host: 'i-1ec3ca3151468928.ec2.internal' | |
| /// pod: 'aqcathnxqsphdhgjtgvxsfyiwbmhlmg' | |
| /// container: 'backend_container_0' | |
| /// image: 'backend_container_0@sha256:30375999bf03beec2187843017b10c9e88d8b1a91615df4eb6350fb39472edd9' | |
| /// time: '1970-01-01 00:00:00' | |
| /// client_addr: '127.216.178.64' | |
| /// request_duration_ns: -1261239112 | |
| /// request_user_agent: 'kxttrfiiietlsaygzphhwlqcgngnumuphliejmxfdznuurswhdcicrlprbnocibvsbukiohjjbjdygwbfhxqvurm' | |
| /// request_method: 'PUT' | |
| /// request_host: 'https://backend.mydomain.com' | |
| /// request_bytes: -312099516 | |
| /// response_bytes: 1448834362 | |
| /// response_status: 200 | |
| /// ``` | |
| #[derive(Debug)] | |
| pub struct AccessLogGenerator { | |
| schema: SchemaRef, | |
| rng: StdRng, | |
| host_idx: usize, | |
| /// maximum rows per batch | |
| max_batch_size: usize, | |
| /// How many rows have been returned so far | |
| row_count: usize, | |
| /// Options | |
| options: GeneratorOptions, | |
| } | |
| impl Default for AccessLogGenerator { | |
| fn default() -> Self { | |
| Self::new() | |
| } | |
| } | |
| impl AccessLogGenerator { | |
| pub fn new() -> Self { | |
| let seed = [ | |
| 1, 0, 0, 0, 23, 0, 3, 0, 200, 1, 0, 0, 210, 30, 8, 0, 1, 0, 21, 0, 6, 0, 0, | |
| 0, 0, 0, 5, 0, 0, 0, 0, 0, | |
| ]; | |
| Self { | |
| schema: BatchBuilder::schema(), | |
| host_idx: 0, | |
| rng: StdRng::from_seed(seed), | |
| max_batch_size: usize::MAX, | |
| row_count: 0, | |
| options: Default::default(), | |
| } | |
| } | |
| /// Return the schema of the [`RecordBatch`]es created | |
| pub fn schema(&self) -> SchemaRef { | |
| self.schema.clone() | |
| } | |
| /// Limit the maximum batch size | |
| pub fn with_max_batch_size(mut self, batch_size: usize) -> Self { | |
| self.max_batch_size = batch_size; | |
| self | |
| } | |
| /// Return up to row_limit rows; | |
| pub fn with_row_limit(mut self, row_limit: usize) -> Self { | |
| self.options.row_limit = row_limit; | |
| self | |
| } | |
| /// Set the number of pods per host | |
| pub fn with_pods_per_host(mut self, range: Range<usize>) -> Self { | |
| self.options.pods_per_host = range; | |
| self | |
| } | |
| /// Set the number of containers per pod | |
| pub fn with_containers_per_pod(mut self, range: Range<usize>) -> Self { | |
| self.options.containers_per_pod = range; | |
| self | |
| } | |
| /// Set the number of log entries per container | |
| pub fn with_entries_per_container(mut self, range: Range<usize>) -> Self { | |
| self.options.entries_per_container = range; | |
| self | |
| } | |
| // Set the condition for null values in the generated data | |
| pub fn with_include_nulls(mut self, include_nulls: bool) -> Self { | |
| self.options.include_nulls = include_nulls; | |
| self | |
| } | |
| } | |
| impl Iterator for AccessLogGenerator { | |
| type Item = RecordBatch; | |
| fn next(&mut self) -> Option<Self::Item> { | |
| if self.row_count == self.options.row_limit { | |
| return None; | |
| } | |
| let row_limit = self | |
| .max_batch_size | |
| .min(self.options.row_limit - self.row_count); | |
| let mut builder = BatchBuilder { | |
| options: GeneratorOptions { | |
| row_limit, | |
| ..self.options.clone() | |
| }, | |
| ..Default::default() | |
| }; | |
| let host = format!( | |
| "i-{:016x}.ec2.internal", | |
| self.host_idx * 0x7d87f8ed5c5 + 0x1ec3ca3151468928 | |
| ); | |
| self.host_idx += 1; | |
| for service in &["frontend", "backend", "database", "cache"] { | |
| if self.rng.random_bool(0.5) { | |
| continue; | |
| } | |
| if builder.is_finished() { | |
| break; | |
| } | |
| builder.append(&mut self.rng, &host, service); | |
| } | |
| let batch = builder.finish(Arc::clone(&self.schema)); | |
| self.row_count += batch.num_rows(); | |
| Some(batch) | |
| } | |
| } |