296 lines
12 KiB
Java
296 lines
12 KiB
Java
package com.demo;
|
|
|
|
import io.micrometer.core.instrument.Counter;
|
|
import io.micrometer.core.instrument.DistributionSummary;
|
|
import io.micrometer.core.instrument.MeterRegistry;
|
|
import io.micrometer.core.instrument.Timer;
|
|
import io.opentelemetry.api.trace.Span;
|
|
import io.opentelemetry.api.trace.SpanKind;
|
|
import io.opentelemetry.api.trace.StatusCode;
|
|
import io.opentelemetry.api.trace.Tracer;
|
|
import io.opentelemetry.context.Scope;
|
|
import jakarta.inject.Inject;
|
|
import jakarta.ws.rs.*;
|
|
import jakarta.ws.rs.core.MediaType;
|
|
import jakarta.ws.rs.core.Response;
|
|
import org.jboss.logging.Logger;
|
|
|
|
import java.time.Duration;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.concurrent.CopyOnWriteArrayList;
|
|
import java.util.concurrent.ThreadLocalRandom;
|
|
|
|
@Path("/api")
|
|
@Produces(MediaType.APPLICATION_JSON)
|
|
public class OrderResource {
|
|
|
|
private static final Logger LOG = Logger.getLogger(OrderResource.class);
|
|
|
|
@Inject
|
|
Tracer tracer;
|
|
|
|
@Inject
|
|
MeterRegistry registry;
|
|
|
|
@Inject
|
|
InventoryService inventoryService;
|
|
|
|
// In-memory store for demo purposes
|
|
private final List<Order> orders = new CopyOnWriteArrayList<>();
|
|
|
|
// ────── Custom Metrics (registered lazily) ──────
|
|
|
|
private Counter ordersCounter(String status) {
|
|
return Counter.builder("orders")
|
|
.description("Total number of orders processed")
|
|
.tag("status", status)
|
|
.register(registry);
|
|
}
|
|
|
|
private Counter revenueCounter() {
|
|
return Counter.builder("orders_amount")
|
|
.description("Total order revenue")
|
|
.baseUnit("dollars")
|
|
.register(registry);
|
|
}
|
|
|
|
private Timer orderProcessingTimer() {
|
|
return Timer.builder("order_processing_duration")
|
|
.description("Time spent processing an order")
|
|
.publishPercentiles(0.5, 0.9, 0.95, 0.99)
|
|
.register(registry);
|
|
}
|
|
|
|
private DistributionSummary orderSizeSummary() {
|
|
return DistributionSummary.builder("order_item_count")
|
|
.description("Number of items per order")
|
|
.publishPercentiles(0.5, 0.9)
|
|
.register(registry);
|
|
}
|
|
|
|
// ────── Endpoints ──────
|
|
|
|
@GET
|
|
@Path("/health")
|
|
public Response health() {
|
|
return Response.ok(Map.of("status", "UP", "service", "otel-quarkus-demo")).build();
|
|
}
|
|
|
|
/**
|
|
* POST /api/orders — Create and process an order.
|
|
* Demonstrates: custom spans, span attributes, custom metrics, error injection, latency simulation.
|
|
*/
|
|
@POST
|
|
@Path("/orders")
|
|
@Consumes(MediaType.APPLICATION_JSON)
|
|
public Response createOrder(OrderRequest request) {
|
|
LOG.infof("Received order request: product=%s, quantity=%d, customerId=%s",
|
|
request.product, request.quantity, request.customerId);
|
|
|
|
// Start a custom parent span for the full order processing
|
|
Span orderSpan = tracer.spanBuilder("processOrder")
|
|
.setSpanKind(SpanKind.INTERNAL)
|
|
.setAttribute("order.product", request.product)
|
|
.setAttribute("order.quantity", (long) request.quantity)
|
|
.setAttribute("order.customer_id", request.customerId)
|
|
.startSpan();
|
|
|
|
try (Scope scope = orderSpan.makeCurrent()) {
|
|
return orderProcessingTimer().record(() -> {
|
|
try {
|
|
// Step 1: Validate the order
|
|
validateOrder(request);
|
|
|
|
// Step 2: Check inventory
|
|
boolean inStock = checkInventory(request.product, request.quantity);
|
|
if (!inStock) {
|
|
orderSpan.setStatus(StatusCode.ERROR, "Insufficient inventory");
|
|
ordersCounter("rejected_no_stock").increment();
|
|
LOG.warnf("Order rejected — insufficient inventory for product=%s, requested=%d",
|
|
request.product, request.quantity);
|
|
return Response.status(Response.Status.CONFLICT)
|
|
.entity(Map.of("error", "Insufficient inventory", "product", request.product))
|
|
.build();
|
|
}
|
|
|
|
// Step 3: Process payment (simulated)
|
|
processPayment(request);
|
|
|
|
// Step 4: Create the order
|
|
Order order = new Order(request.customerId, request.product,
|
|
request.quantity, request.unitPrice);
|
|
|
|
// Simulate processing time based on complexity
|
|
String processingType = request.quantity > 5 ? "complex" : "simple";
|
|
orderSpan.setAttribute("order.processing_type", processingType);
|
|
orderSpan.setAttribute("order.total_price", order.totalPrice);
|
|
|
|
simulateProcessingDelay(processingType);
|
|
|
|
// Randomly inject errors (~10% of the time) for troubleshooting demo
|
|
if (ThreadLocalRandom.current().nextDouble() < 0.10) {
|
|
throw new RuntimeException("Downstream fulfillment service timeout");
|
|
}
|
|
|
|
order.status = "COMPLETED";
|
|
orders.add(order);
|
|
|
|
// Record metrics
|
|
ordersCounter("completed").increment();
|
|
revenueCounter().increment(order.totalPrice);
|
|
orderSizeSummary().record(request.quantity);
|
|
|
|
// Update inventory
|
|
inventoryService.decrementStock(request.product, request.quantity);
|
|
|
|
LOG.infof("Order %s completed: product=%s, quantity=%d, total=%.2f, type=%s",
|
|
order.id, order.product, order.quantity, order.totalPrice, processingType);
|
|
|
|
orderSpan.setAttribute("order.id", order.id);
|
|
orderSpan.setStatus(StatusCode.OK);
|
|
|
|
return Response.status(Response.Status.CREATED).entity(order).build();
|
|
|
|
} catch (IllegalArgumentException e) {
|
|
orderSpan.setStatus(StatusCode.ERROR, e.getMessage());
|
|
orderSpan.recordException(e);
|
|
ordersCounter("rejected_validation").increment();
|
|
LOG.errorf("Order validation failed: %s", e.getMessage());
|
|
return Response.status(Response.Status.BAD_REQUEST)
|
|
.entity(Map.of("error", e.getMessage())).build();
|
|
|
|
} catch (RuntimeException e) {
|
|
orderSpan.setStatus(StatusCode.ERROR, e.getMessage());
|
|
orderSpan.recordException(e);
|
|
ordersCounter("failed").increment();
|
|
LOG.errorf(e, "Order processing failed: %s", e.getMessage());
|
|
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
|
.entity(Map.of("error", "Order processing failed", "detail", e.getMessage())).build();
|
|
}
|
|
});
|
|
} finally {
|
|
orderSpan.end();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /api/orders — List all orders.
|
|
*/
|
|
@GET
|
|
@Path("/orders")
|
|
public Response listOrders() {
|
|
LOG.debugf("Listing %d orders", orders.size());
|
|
return Response.ok(orders).build();
|
|
}
|
|
|
|
/**
|
|
* GET /api/orders/{id} — Get a single order by ID.
|
|
*/
|
|
@GET
|
|
@Path("/orders/{id}")
|
|
public Response getOrder(@PathParam("id") String id) {
|
|
return orders.stream()
|
|
.filter(o -> o.id.equals(id))
|
|
.findFirst()
|
|
.map(o -> Response.ok(o).build())
|
|
.orElseGet(() -> {
|
|
LOG.warnf("Order not found: %s", id);
|
|
return Response.status(Response.Status.NOT_FOUND)
|
|
.entity(Map.of("error", "Order not found")).build();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* GET /api/inventory — Current inventory levels (gauge metrics).
|
|
*/
|
|
@GET
|
|
@Path("/inventory")
|
|
public Response getInventory() {
|
|
return Response.ok(inventoryService.getInventoryLevels()).build();
|
|
}
|
|
|
|
// ────── Internal Processing Steps (each with its own span) ──────
|
|
|
|
private void validateOrder(OrderRequest request) {
|
|
Span span = tracer.spanBuilder("validateOrder").startSpan();
|
|
try (Scope s = span.makeCurrent()) {
|
|
if (request.product == null || request.product.isBlank()) {
|
|
throw new IllegalArgumentException("Product name is required");
|
|
}
|
|
if (request.quantity <= 0) {
|
|
throw new IllegalArgumentException("Quantity must be positive");
|
|
}
|
|
if (request.unitPrice <= 0) {
|
|
throw new IllegalArgumentException("Unit price must be positive");
|
|
}
|
|
span.setAttribute("validation.passed", true);
|
|
LOG.debugf("Order validation passed for product=%s", request.product);
|
|
} finally {
|
|
span.end();
|
|
}
|
|
}
|
|
|
|
private boolean checkInventory(String product, int quantity) {
|
|
Span span = tracer.spanBuilder("checkInventory")
|
|
.setAttribute("inventory.product", product)
|
|
.setAttribute("inventory.requested_quantity", (long) quantity)
|
|
.startSpan();
|
|
try (Scope s = span.makeCurrent()) {
|
|
// Simulate a small network call delay
|
|
Thread.sleep(ThreadLocalRandom.current().nextInt(10, 50));
|
|
int available = inventoryService.getStock(product);
|
|
span.setAttribute("inventory.available", (long) available);
|
|
boolean inStock = available >= quantity;
|
|
span.setAttribute("inventory.in_stock", inStock);
|
|
LOG.debugf("Inventory check: product=%s, available=%d, requested=%d, inStock=%s",
|
|
product, available, quantity, inStock);
|
|
return inStock;
|
|
} catch (InterruptedException e) {
|
|
Thread.currentThread().interrupt();
|
|
return false;
|
|
} finally {
|
|
span.end();
|
|
}
|
|
}
|
|
|
|
private void processPayment(OrderRequest request) {
|
|
Span span = tracer.spanBuilder("processPayment")
|
|
.setSpanKind(SpanKind.CLIENT)
|
|
.setAttribute("payment.amount", request.quantity * request.unitPrice)
|
|
.setAttribute("payment.currency", "USD")
|
|
.startSpan();
|
|
try (Scope s = span.makeCurrent()) {
|
|
// Simulate payment gateway latency
|
|
Thread.sleep(ThreadLocalRandom.current().nextInt(20, 150));
|
|
span.setAttribute("payment.status", "approved");
|
|
LOG.infof("Payment processed: amount=%.2f", request.quantity * request.unitPrice);
|
|
} catch (InterruptedException e) {
|
|
Thread.currentThread().interrupt();
|
|
} finally {
|
|
span.end();
|
|
}
|
|
}
|
|
|
|
private void simulateProcessingDelay(String type) {
|
|
try {
|
|
if ("complex".equals(type)) {
|
|
Thread.sleep(ThreadLocalRandom.current().nextInt(200, 800));
|
|
} else {
|
|
Thread.sleep(ThreadLocalRandom.current().nextInt(10, 100));
|
|
}
|
|
} catch (InterruptedException e) {
|
|
Thread.currentThread().interrupt();
|
|
}
|
|
}
|
|
|
|
// ────── Request DTO ──────
|
|
|
|
public static class OrderRequest {
|
|
public String customerId;
|
|
public String product;
|
|
public int quantity;
|
|
public double unitPrice;
|
|
}
|
|
}
|