featherbit/plugins/native/
skywalking_logger.rs1use std::collections::HashMap;
20use std::sync::Arc;
21use std::time::Duration;
22
23use async_trait::async_trait;
24use serde_json::{json, Value};
25
26use crate::batch::{BatchConfig, BatchFlusher, BatchSink, FlushError};
27use crate::context::Context;
28use crate::outbound::{OutboundClient, OutboundRequest};
29use crate::plugins::resources::PluginResources;
30use crate::plugins::util::log_entry::{build_entry, parse_log_format, LogFormat};
31use crate::plugins::{Plugin, PluginOutput, PluginResult};
32
33pub struct SkywalkingLoggerPlugin {
35 sink: BatchSink,
36 log_format: Option<LogFormat>,
37 include_req_body: bool,
38 include_resp_body: bool,
39 service_name: String,
40 service_instance_name: String,
41}
42
43struct SkywalkingFlusher {
45 client: Arc<OutboundClient>,
46 url: String,
47 timeout: Duration,
48 ssl_verify: bool,
49}
50
51impl SkywalkingLoggerPlugin {
52 pub fn from_config(
79 config: &HashMap<String, Value>,
80 resources: &Arc<PluginResources>,
81 ) -> Result<Self, String> {
82 let endpoint_addr = config
83 .get("endpoint_addr")
84 .and_then(|v| v.as_str())
85 .filter(|s| !s.is_empty())
86 .ok_or("skywalking-logger: `endpoint_addr` is required")?
87 .trim_end_matches('/')
88 .to_string();
89
90 let service_name = config
91 .get("service_name")
92 .and_then(|v| v.as_str())
93 .unwrap_or("featherbit")
94 .to_string();
95 let service_instance_name = config
96 .get("service_instance_name")
97 .and_then(|v| v.as_str())
98 .unwrap_or("featherbit Instance Name")
99 .to_string();
100 let ssl_verify = config
101 .get("ssl_verify")
102 .and_then(|v| v.as_bool())
103 .unwrap_or(true);
104 let timeout =
105 Duration::from_secs(config.get("timeout").and_then(|v| v.as_u64()).unwrap_or(3));
106
107 let log_format = parse_log_format(config)?;
108 let include_req_body = bool_key(config, "include_req_body");
109 let include_resp_body = bool_key(config, "include_resp_body");
110
111 let batch_cfg =
112 BatchConfig::from_config(config).map_err(|e| format!("skywalking-logger: {e}"))?;
113
114 let flusher = Arc::new(SkywalkingFlusher {
115 client: resources.outbound.clone(),
116 url: format!("{endpoint_addr}/v3/logs"),
117 timeout,
118 ssl_verify,
119 });
120 let sink = BatchSink::spawn("skywalking-logger", batch_cfg, flusher);
121
122 Ok(Self {
123 sink,
124 log_format,
125 include_req_body,
126 include_resp_body,
127 service_name,
128 service_instance_name,
129 })
130 }
131}
132
133fn build_log_item(
136 entry: &Value,
137 service: &str,
138 service_instance: &str,
139 endpoint: &str,
140 timestamp_ms: u64,
141) -> Value {
142 let entry_str = serde_json::to_string(entry).unwrap_or_else(|_| "{}".to_string());
143 json!({
144 "service": service,
145 "serviceInstance": service_instance,
146 "endpoint": endpoint,
147 "timestamp": timestamp_ms,
148 "body": { "json": { "json": entry_str } },
149 })
150}
151
152fn bool_key(config: &HashMap<String, Value>, key: &str) -> bool {
153 config.get(key).and_then(|v| v.as_bool()).unwrap_or(false)
154}
155
156fn now_ms() -> u64 {
157 std::time::SystemTime::now()
158 .duration_since(std::time::UNIX_EPOCH)
159 .map(|d| d.as_millis() as u64)
160 .unwrap_or(0)
161}
162
163#[async_trait]
164impl BatchFlusher for SkywalkingFlusher {
165 async fn flush(&self, entries: &[Value]) -> Result<(), FlushError> {
166 let body = serde_json::to_vec(&Value::Array(entries.to_vec())).map_err(|e| FlushError {
167 message: format!("failed to encode log batch: {e}"),
168 first_fail: None,
169 })?;
170
171 let req = OutboundRequest {
172 method: http::Method::POST,
173 url: self.url.clone(),
174 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
175 body: body.into(),
176 timeout: self.timeout,
177 ssl_verify: self.ssl_verify,
178 tls: None,
179 };
180
181 match self.client.request(req).await {
182 Ok(resp) if resp.status < 400 => Ok(()),
183 Ok(resp) => Err(FlushError {
184 message: format!(
185 "skywalking OAP returned status {}: {}",
186 resp.status,
187 String::from_utf8_lossy(&resp.body)
188 ),
189 first_fail: None,
190 }),
191 Err(e) => Err(FlushError {
192 message: e.to_string(),
193 first_fail: None,
194 }),
195 }
196 }
197}
198
199#[async_trait]
200impl Plugin for SkywalkingLoggerPlugin {
201 fn plugin_type(&self) -> &str {
202 "skywalking-logger"
203 }
204
205 fn reads_response_body(&self) -> bool {
206 crate::plugins::util::log_entry::reads_response_body(
207 self.log_format.as_ref(),
208 self.include_resp_body,
209 )
210 }
211
212 async fn execute(&self, ctx: Context) -> PluginResult {
213 let entry = build_entry(
214 &ctx,
215 self.log_format.as_ref(),
216 self.include_req_body,
217 self.include_resp_body,
218 );
219 let item = build_log_item(
220 &entry,
221 &self.service_name,
222 &self.service_instance_name,
223 &ctx.request.path,
224 now_ms(),
225 );
226 self.sink.push(item);
227
228 Ok(PluginOutput::success(ctx))
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 fn cfg(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
237 pairs
238 .iter()
239 .map(|(k, v)| (k.to_string(), v.clone()))
240 .collect()
241 }
242
243 #[test]
244 fn from_config_requires_endpoint() {
245 let res = SkywalkingLoggerPlugin::from_config(&HashMap::new(), &PluginResources::empty());
246 let Err(e) = res else {
247 panic!("expected error")
248 };
249 assert!(e.contains("endpoint_addr"));
250 }
251
252 #[tokio::test]
253 async fn from_config_defaults() {
254 let c = cfg(&[("endpoint_addr", json!("http://oap:12800/"))]);
255 let p = SkywalkingLoggerPlugin::from_config(&c, &PluginResources::empty()).unwrap();
256 assert_eq!(p.service_name, "featherbit");
257 assert_eq!(p.service_instance_name, "featherbit Instance Name");
258 assert!(!p.include_req_body);
259 }
260
261 #[test]
262 fn log_item_shape() {
263 let entry = json!({ "request": { "method": "GET" }, "response": { "status": 200 } });
264 let item = build_log_item(&entry, "svc", "inst", "/api/x", 1_700_000_000_000);
265 assert_eq!(item["service"], "svc");
266 assert_eq!(item["serviceInstance"], "inst");
267 assert_eq!(item["endpoint"], "/api/x");
268 assert_eq!(item["timestamp"], 1_700_000_000_000u64);
269 let embedded = item["body"]["json"]["json"].as_str().unwrap();
271 let reparsed: Value = serde_json::from_str(embedded).unwrap();
272 assert_eq!(reparsed["request"]["method"], "GET");
273 assert_eq!(reparsed["response"]["status"], 200);
274 }
275}