1use async_trait::async_trait;
44use std::collections::HashMap;
45use std::sync::Arc;
46use std::time::Duration;
47
48use serde_json::{json, Value};
49
50use crate::context::Context;
51use crate::outbound::{OutboundClient, OutboundRequest};
52use crate::plugins::resources::PluginResources;
53use crate::plugins::util::trace::{
54 load_span, new_span_id, new_trace_id, now_ms, parse_sw8, store_span, sw8_encode, SpanContext,
55};
56use crate::plugins::{Plugin, PluginOutput, PluginResult};
57
58const COMPONENT_ID_HTTP: i64 = 49;
60
61#[derive(Debug, Clone, Copy, PartialEq)]
63enum Phase {
64 Start,
66 End,
68}
69
70pub struct SkywalkingPlugin {
72 phase: Phase,
73 endpoint_addr: String,
75 service_name: String,
76 service_instance_name: String,
77 sample_ratio: f64,
79 ssl_verify: bool,
80 timeout: Duration,
81 client: Arc<OutboundClient>,
83}
84
85fn roll_fraction() -> f64 {
88 use std::collections::hash_map::RandomState;
89 use std::hash::{BuildHasher, Hasher};
90 let n = RandomState::new().build_hasher().finish();
91 n as f64 / (u64::MAX as f64 + 1.0)
92}
93
94fn build_sw8(span: &SpanContext, service: &str, instance: &str, endpoint: &str) -> String {
100 format!(
101 "{}-{}-{}-{}-{}-{}-{}-{}",
102 if span.sampled { "1" } else { "0" },
103 sw8_encode(&span.trace_id),
104 sw8_encode(&span.span_id), "0", sw8_encode(service),
107 sw8_encode(instance),
108 sw8_encode(endpoint),
109 sw8_encode(endpoint), )
111}
112
113fn build_segment(
116 span: &SpanContext,
117 service: &str,
118 instance: &str,
119 method: &str,
120 path: &str,
121 status: u16,
122 end_ms: u64,
123) -> Value {
124 json!({
125 "traceId": span.trace_id,
126 "traceSegmentId": span.span_id,
127 "service": service,
128 "serviceInstance": instance,
129 "spans": [{
130 "spanId": 0,
131 "parentSpanId": -1,
132 "startTime": span.start_ms,
133 "endTime": end_ms,
134 "operationName": path,
135 "spanType": "Entry",
136 "spanLayer": "Http",
137 "componentId": COMPONENT_ID_HTTP,
138 "isError": status >= 400,
139 "tags": [
140 { "key": "http.method", "value": method },
141 { "key": "http.path", "value": path },
142 { "key": "http.status_code", "value": status.to_string() },
143 ],
144 }],
145 })
146}
147
148impl SkywalkingPlugin {
149 pub fn from_config(
174 config: &HashMap<String, Value>,
175 resources: &Arc<PluginResources>,
176 ) -> Result<Self, String> {
177 let phase = match config.get("phase").and_then(|v| v.as_str()) {
178 Some("start") => Phase::Start,
179 Some("end") => Phase::End,
180 _ => {
181 return Err(
182 "skywalking requires `phase: start` (before upstream) or `phase: end` (after upstream)"
183 .to_string(),
184 )
185 }
186 };
187
188 let endpoint_addr = config
189 .get("endpoint_addr")
190 .and_then(|v| v.as_str())
191 .filter(|s| !s.is_empty())
192 .unwrap_or("http://127.0.0.1:12800")
193 .trim_end_matches('/')
194 .to_string();
195
196 let service_name = config
197 .get("service_name")
198 .and_then(|v| v.as_str())
199 .unwrap_or("featherbit")
200 .to_string();
201
202 let service_instance_name = config
203 .get("service_instance_name")
204 .and_then(|v| v.as_str())
205 .unwrap_or("featherbit Instance Name")
206 .to_string();
207
208 let sample_ratio = match config.get("sample_ratio") {
209 None => 1.0,
210 Some(v) => v
211 .as_f64()
212 .filter(|r| (0.0..=1.0).contains(r))
213 .ok_or("skywalking `sample_ratio` must be a number in 0.0..=1.0")?,
214 };
215
216 let ssl_verify = config
217 .get("ssl_verify")
218 .and_then(|v| v.as_bool())
219 .unwrap_or(true);
220
221 let timeout =
222 Duration::from_secs(config.get("timeout").and_then(|v| v.as_u64()).unwrap_or(3));
223
224 Ok(Self {
225 phase,
226 endpoint_addr,
227 service_name,
228 service_instance_name,
229 sample_ratio,
230 ssl_verify,
231 timeout,
232 client: resources.outbound.clone(),
233 })
234 }
235
236 fn should_sample(&self) -> bool {
238 if self.sample_ratio >= 1.0 {
239 true
240 } else if self.sample_ratio <= 0.0 {
241 false
242 } else {
243 roll_fraction() < self.sample_ratio
244 }
245 }
246
247 fn run_start(&self, mut ctx: Context) -> Context {
250 let incoming = ctx
251 .request
252 .headers
253 .get("sw8")
254 .and_then(|v| v.first())
255 .and_then(|s| parse_sw8(s));
256
257 let (trace_id, parent_span_id, sampled) = match incoming {
258 Some((trace_id, parent, sampled)) => (trace_id, Some(parent), sampled),
259 None => (new_trace_id(), None, self.should_sample()),
260 };
261
262 let span = SpanContext {
263 trace_id,
264 span_id: new_span_id(),
265 parent_span_id,
266 sampled,
267 start_ms: now_ms(),
268 };
269 store_span(&mut ctx, &span);
270
271 let sw8 = build_sw8(
272 &span,
273 &self.service_name,
274 &self.service_instance_name,
275 &ctx.request.path,
276 );
277 ctx.request.headers.insert("sw8".to_string(), vec![sw8]);
278
279 ctx
280 }
281
282 fn run_end(&self, ctx: Context) -> Context {
285 if let Some(span) = load_span(&ctx) {
286 if span.sampled {
287 let segment = build_segment(
288 &span,
289 &self.service_name,
290 &self.service_instance_name,
291 &ctx.request.method,
292 &ctx.request.path,
293 ctx.response.status_code,
294 now_ms(),
295 );
296 let body = serde_json::to_vec(&segment).unwrap_or_default();
297 let req = OutboundRequest {
298 method: http::Method::POST,
299 url: format!("{}/v3/segments", self.endpoint_addr),
300 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
301 body: body.into(),
302 timeout: self.timeout,
303 ssl_verify: self.ssl_verify,
304 tls: None,
305 };
306 let client = self.client.clone();
307 tokio::spawn(async move {
308 let _ = client.request(req).await;
309 });
310 }
311 }
312 ctx
313 }
314}
315
316#[async_trait]
317impl Plugin for SkywalkingPlugin {
318 fn plugin_type(&self) -> &str {
319 "skywalking"
320 }
321
322 fn reads_response_body(&self) -> bool {
323 false
324 }
325
326 async fn execute(&self, ctx: Context) -> PluginResult {
327 let ctx = match self.phase {
328 Phase::Start => self.run_start(ctx),
329 Phase::End => self.run_end(ctx),
330 };
331 Ok(PluginOutput::success(ctx))
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
339 use bytes::Bytes;
340
341 fn test_ctx() -> Context {
342 Context {
343 request: GatewayRequest {
344 method: "GET".to_string(),
345 path: "/api/users".to_string(),
346 host: "api.example.com".to_string(),
347 scheme: "http".to_string(),
348 headers: HashMap::new(),
349 query_params: HashMap::new(),
350 body: Bytes::new(),
351 remote_addr: "10.0.0.1:1234".to_string(),
352 protocol: Protocol::Http1,
353 },
354 response: GatewayResponse {
355 status_code: 200,
356 headers: HashMap::new(),
357 body: Bytes::new(),
358 stream: None,
359 },
360 message: HashMap::new(),
361 errors: Vec::new(),
362 }
363 }
364
365 fn plugin(config: Value) -> Result<SkywalkingPlugin, String> {
366 let map: HashMap<String, Value> = serde_json::from_value(config).unwrap();
367 SkywalkingPlugin::from_config(&map, &PluginResources::empty())
368 }
369
370 #[test]
371 fn from_config_requires_phase() {
372 assert!(plugin(json!({})).is_err());
373 assert!(plugin(json!({ "phase": "middle" })).is_err());
374 assert!(plugin(json!({ "phase": "start" })).is_ok());
375 assert!(plugin(json!({ "phase": "end" })).is_ok());
376 assert!(plugin(json!({ "phase": "start", "sample_ratio": 2 })).is_err());
378 }
379
380 #[test]
381 fn from_config_defaults() {
382 let p = plugin(json!({ "phase": "end" })).unwrap();
383 assert_eq!(p.endpoint_addr, "http://127.0.0.1:12800");
384 assert_eq!(p.service_name, "featherbit");
385 assert_eq!(p.service_instance_name, "featherbit Instance Name");
386 assert_eq!(p.sample_ratio, 1.0);
387 assert!(p.ssl_verify);
388 }
389
390 #[test]
391 fn sampler_decision() {
392 let never = plugin(json!({ "phase": "start", "sample_ratio": 0 })).unwrap();
393 let always = plugin(json!({ "phase": "start", "sample_ratio": 1 })).unwrap();
394 for _ in 0..100 {
395 assert!(!never.should_sample(), "ratio 0 must never sample");
396 assert!(always.should_sample(), "ratio 1 must always sample");
397 }
398 }
399
400 #[test]
401 fn start_stores_span_and_injects_sw8() {
402 let p = plugin(json!({ "phase": "start", "sample_ratio": 1 })).unwrap();
403 let ctx = p.run_start(test_ctx());
404
405 let span = load_span(&ctx).expect("start node must store a span");
407 assert_eq!(span.trace_id.len(), 32);
408 assert_eq!(span.span_id.len(), 16);
409 assert!(span.sampled);
410
411 let header = ctx.request.headers.get("sw8").unwrap().first().unwrap();
413 let (trace_id, parent_span, sampled) = parse_sw8(header).expect("injected sw8 must parse");
414 assert_eq!(trace_id, span.trace_id);
415 assert_eq!(parent_span, "0");
416 assert!(sampled);
417 }
418
419 #[test]
420 fn start_continues_incoming_trace() {
421 let p = plugin(json!({ "phase": "start", "sample_ratio": 0 })).unwrap();
422 let incoming_trace = "1f2d4bf47bf711eab794acde48001122";
423 let incoming = format!(
424 "1-{}-{}-7-{}-{}-{}-{}",
425 sw8_encode(incoming_trace),
426 sw8_encode("parent-segment"),
427 sw8_encode("caller-svc"),
428 sw8_encode("caller-inst"),
429 sw8_encode("/caller"),
430 sw8_encode("peer:80"),
431 );
432 let mut ctx = test_ctx();
433 ctx.request
434 .headers
435 .insert("sw8".to_string(), vec![incoming]);
436
437 let ctx = p.run_start(ctx);
438 let span = load_span(&ctx).unwrap();
439 assert_eq!(span.trace_id, incoming_trace);
442 assert!(span.sampled);
443 assert_eq!(span.parent_span_id.as_deref(), Some("7"));
444 }
445
446 #[test]
447 fn segment_payload_shape() {
448 let span = SpanContext {
449 trace_id: "1f2d4bf47bf711eab794acde48001122".to_string(),
450 span_id: "b7ad6b7169203331".to_string(),
451 parent_span_id: None,
452 sampled: true,
453 start_ms: 1_700_000_000_000,
454 };
455 let seg = build_segment(
456 &span,
457 "svc",
458 "inst",
459 "POST",
460 "/api/x",
461 500,
462 1_700_000_000_123,
463 );
464 assert_eq!(seg["traceId"], span.trace_id);
465 assert_eq!(seg["traceSegmentId"], span.span_id);
466 assert_eq!(seg["service"], "svc");
467 assert_eq!(seg["serviceInstance"], "inst");
468 let s = &seg["spans"][0];
469 assert_eq!(s["spanId"], 0);
470 assert_eq!(s["parentSpanId"], -1);
471 assert_eq!(s["spanType"], "Entry");
472 assert_eq!(s["spanLayer"], "Http");
473 assert_eq!(s["componentId"], COMPONENT_ID_HTTP);
474 assert_eq!(s["operationName"], "/api/x");
475 assert_eq!(s["startTime"], 1_700_000_000_000u64);
476 assert_eq!(s["endTime"], 1_700_000_000_123u64);
477 assert_eq!(s["isError"], true);
479 assert_eq!(s["tags"][0]["key"], "http.method");
480 assert_eq!(s["tags"][0]["value"], "POST");
481 assert_eq!(s["tags"][2]["value"], "500");
482 }
483
484 #[tokio::test]
485 async fn end_returns_ok_without_span() {
486 let p = plugin(json!({ "phase": "end" })).unwrap();
488 let out = p.execute(test_ctx()).await.unwrap();
489 assert_eq!(out.context.response.status_code, 200);
490 }
491
492 #[tokio::test]
493 async fn end_returns_ok_and_spawns_export_when_sampled() {
494 let p = plugin(json!({ "phase": "end", "endpoint_addr": "http://127.0.0.1:1" })).unwrap();
496 let start = plugin(json!({ "phase": "start", "sample_ratio": 1 })).unwrap();
497 let ctx = start.run_start(test_ctx());
498 let out = p.execute(ctx).await.unwrap();
499 assert_eq!(out.context.request.path, "/api/users");
501 }
502
503 #[tokio::test]
504 async fn end_skips_export_when_not_sampled() {
505 let p = plugin(json!({ "phase": "end" })).unwrap();
506 let start = plugin(json!({ "phase": "start", "sample_ratio": 0 })).unwrap();
507 let ctx = start.run_start(test_ctx());
508 assert!(!load_span(&ctx).unwrap().sampled);
509 let out = p.execute(ctx).await.unwrap();
510 assert_eq!(out.context.request.path, "/api/users");
511 }
512}