Putting It All Together
View SourceThis chapter brings the pieces together in one service. The goal is to show how metrics, traces, logs, propagation, export, and sampling fit into ordinary application code.
The Application
We will build an order processing service with:
- HTTP API for creating orders
- Database operations
- External payment service integration
- Background order processing
Project Structure
order_service/
├── rebar.config
├── config/
│ └── sys.config
├── src/
│ ├── order_service_app.erl
│ ├── order_service_sup.erl
│ ├── order_service_telemetry.erl
│ ├── order_service_http.erl
│ ├── order_service_handler.erl
│ ├── order_service_db.erl
│ └── order_service_payment.erl
└── priv/
└── schema.sqlDependencies
%% rebar.config
{deps, [
{instrument, "0.3.0"},
{cowboy, "2.10.0"},
{hackney, "1.20.1"},
{jiffy, "1.1.1"}
]}.Configuration
%% config/sys.config
[
{order_service, [
{http_port, 8080},
{db_pool_size, 10},
{payment_service_url, "http://payment-service:8080"}
]},
{instrument, [
{service_name, <<"order-service">>}
]}
].Telemetry Setup
%% src/order_service_telemetry.erl
-module(order_service_telemetry).
-export([init/0]).
init() ->
%% Initialize from environment
instrument_config:init(),
%% Create metrics
create_metrics(),
%% Set up span processor
setup_tracing(),
%% Set up logging
setup_logging(),
ok.
create_metrics() ->
%% HTTP metrics
instrument_metric:new_counter_vec(
http_requests_total,
<<"Total HTTP requests">>,
[method, status, endpoint]
),
instrument_metric:new_histogram_vec(
http_request_duration_seconds,
<<"HTTP request duration">>,
[method, endpoint]
),
instrument_metric:new_gauge(
http_active_requests,
<<"Currently active HTTP requests">>
),
%% Database metrics
instrument_metric:new_counter_vec(
db_queries_total,
<<"Total database queries">>,
[operation]
),
instrument_metric:new_histogram_vec(
db_query_duration_seconds,
<<"Database query duration">>,
[operation]
),
instrument_metric:new_gauge(
db_pool_connections_active,
<<"Active database connections">>
),
%% Business metrics
instrument_metric:new_counter_vec(
orders_total,
<<"Total orders">>,
[status]
),
instrument_metric:new_histogram(
order_value_dollars,
<<"Order value distribution">>,
[10, 25, 50, 100, 250, 500, 1000]
),
ok.
setup_tracing() ->
case os:getenv("OTEL_EXPORTER_OTLP_ENDPOINT") of
false ->
%% Development: console export
instrument_exporter:register(instrument_exporter_console:new());
Endpoint ->
%% Production: batch OTLP export
ok = instrument_span_processor:register(instrument_span_processor_batch, #{
exporter => instrument_exporter_otlp,
exporter_config => #{endpoint => Endpoint ++ "/v1/traces"},
max_queue_size => 2048,
schedule_delay_millis => 5000,
max_export_batch_size => 512
})
end,
ok.
setup_logging() ->
case os:getenv("OTEL_EXPORTER_OTLP_ENDPOINT") of
false ->
%% Just add trace context to logs
instrument_logger:install();
Endpoint ->
%% Export logs via OTLP
LogExporter = instrument_log_exporter_otlp:new(#{
endpoint => Endpoint ++ "/v1/logs"
}),
instrument_log_exporter:register(LogExporter),
instrument_logger:install(#{exporter => true})
end,
ok.HTTP Handler
%% src/order_service_http.erl
-module(order_service_http).
-export([init/2]).
init(Req0, State) ->
%% Extract trace context from headers
Headers = cowboy_req:headers(Req0),
Ctx = instrument_propagation:extract_headers(maps:to_list(Headers)),
Token = instrument_context:attach(Ctx),
%% Track active requests
instrument_metric:inc_gauge(http_active_requests),
Method = cowboy_req:method(Req0),
Path = cowboy_req:path(Req0),
try
instrument_tracer:with_span(<<"http_request">>, #{kind => server}, fun() ->
instrument_tracer:set_attributes(#{
<<"http.method">> => Method,
<<"http.target">> => Path,
<<"http.scheme">> => <<"http">>
}),
%% Route and handle
Start = erlang:monotonic_time(microsecond),
{Status, RespHeaders, Body} = route(Method, Path, Req0),
Duration = (erlang:monotonic_time(microsecond) - Start) / 1000000,
%% Record metrics
Endpoint = normalize_path(Path),
instrument_metric:inc_counter_vec(http_requests_total, [Method, integer_to_binary(Status), Endpoint]),
instrument_metric:observe_histogram_vec(http_request_duration_seconds, [Method, Endpoint], Duration),
%% Set span attributes
instrument_tracer:set_attribute(<<"http.status_code">>, Status),
case Status >= 400 of
true -> instrument_tracer:set_status(error);
false -> instrument_tracer:set_status(ok)
end,
Req = cowboy_req:reply(Status, RespHeaders, Body, Req0),
{ok, Req, State}
end)
after
instrument_metric:dec_gauge(http_active_requests),
instrument_context:detach(Token)
end.
route(<<"POST">>, <<"/orders">>, Req) ->
order_service_handler:create_order(Req);
route(<<"GET">>, <<"/orders/", OrderId/binary>>, _Req) ->
order_service_handler:get_order(OrderId);
route(<<"GET">>, <<"/health">>, _Req) ->
{200, #{}, <<"{\"status\":\"ok\"}">>};
route(<<"GET">>, <<"/metrics">>, _Req) ->
Body = instrument_prometheus:format(),
{200, #{<<"content-type">> => instrument_prometheus:content_type()}, Body};
route(_, _, _) ->
{404, #{}, <<"{\"error\":\"not_found\"}">>}.
normalize_path(<<"/orders/", _/binary>>) -> <<"/orders/{id}">>;
normalize_path(Path) -> Path.Order Handler
%% src/order_service_handler.erl
-module(order_service_handler).
-export([create_order/1, get_order/1]).
create_order(Req) ->
instrument_tracer:with_span(<<"create_order">>, fun() ->
{ok, Body, _} = cowboy_req:read_body(Req),
Order = jiffy:decode(Body, [return_maps]),
OrderId = generate_order_id(),
instrument_tracer:set_attributes(#{
<<"order.id">> => OrderId,
<<"order.items">> => length(maps:get(<<"items">>, Order, []))
}),
logger:info("Creating order", #{order_id => OrderId}),
%% Validate order
case validate_order(Order) of
{error, Reason} ->
logger:warning("Order validation failed: ~p", [Reason]),
instrument_tracer:set_status(error, <<"Validation failed">>),
instrument_metric:inc_counter_vec(orders_total, [<<"validation_failed">>]),
{400, #{}, jiffy:encode(#{error => Reason})};
ok ->
instrument_tracer:add_event(<<"order_validated">>),
%% Calculate total
Total = calculate_total(Order),
instrument_tracer:set_attribute(<<"order.total">>, Total),
instrument_metric:observe_histogram(order_value_dollars, Total),
%% Process payment
case process_payment(OrderId, Total, Order) of
{ok, PaymentId} ->
instrument_tracer:add_event(<<"payment_completed">>, #{
<<"payment.id">> => PaymentId
}),
%% Save order
case save_order(OrderId, Order, PaymentId) of
ok ->
logger:info("Order created successfully"),
instrument_tracer:set_status(ok),
instrument_metric:inc_counter_vec(orders_total, [<<"completed">>]),
{201, #{}, jiffy:encode(#{
order_id => OrderId,
payment_id => PaymentId,
total => Total
})};
{error, DbError} ->
logger:error("Failed to save order: ~p", [DbError]),
instrument_tracer:record_exception(DbError),
instrument_tracer:set_status(error, <<"Database error">>),
instrument_metric:inc_counter_vec(orders_total, [<<"db_error">>]),
{500, #{}, jiffy:encode(#{error => <<"internal_error">>})}
end;
{error, PaymentError} ->
logger:warning("Payment failed: ~p", [PaymentError]),
instrument_tracer:set_status(error, <<"Payment failed">>),
instrument_metric:inc_counter_vec(orders_total, [<<"payment_failed">>]),
{402, #{}, jiffy:encode(#{error => <<"payment_failed">>})}
end
end
end).
get_order(OrderId) ->
instrument_tracer:with_span(<<"get_order">>, fun() ->
instrument_tracer:set_attribute(<<"order.id">>, OrderId),
case order_service_db:get_order(OrderId) of
{ok, Order} ->
instrument_tracer:set_status(ok),
{200, #{}, jiffy:encode(Order)};
{error, not_found} ->
instrument_tracer:set_status(error, <<"Not found">>),
{404, #{}, jiffy:encode(#{error => <<"not_found">>})}
end
end).
validate_order(Order) ->
instrument_tracer:with_span(<<"validate_order">>, fun() ->
Items = maps:get(<<"items">>, Order, []),
case Items of
[] -> {error, <<"no_items">>};
_ -> ok
end
end).
calculate_total(Order) ->
Items = maps:get(<<"items">>, Order, []),
lists:foldl(fun(Item, Acc) ->
Price = maps:get(<<"price">>, Item, 0),
Qty = maps:get(<<"quantity">>, Item, 1),
Acc + (Price * Qty)
end, 0, Items).
process_payment(OrderId, Total, Order) ->
order_service_payment:charge(OrderId, Total, Order).
save_order(OrderId, Order, PaymentId) ->
order_service_db:insert_order(OrderId, Order, PaymentId).
generate_order_id() ->
list_to_binary(uuid:to_string(uuid:v4())).Database Layer
%% src/order_service_db.erl
-module(order_service_db).
-export([insert_order/3, get_order/1]).
insert_order(OrderId, Order, PaymentId) ->
instrument_tracer:with_span(<<"db_insert_order">>, #{kind => client}, fun() ->
instrument_tracer:set_attributes(#{
<<"db.system">> => <<"postgresql">>,
<<"db.operation">> => <<"INSERT">>,
<<"db.table">> => <<"orders">>
}),
Start = erlang:monotonic_time(microsecond),
%% Simulated database insert
Result = do_insert(OrderId, Order, PaymentId),
Duration = (erlang:monotonic_time(microsecond) - Start) / 1000000,
instrument_metric:inc_counter_vec(db_queries_total, [<<"INSERT">>]),
instrument_metric:observe_histogram_vec(db_query_duration_seconds, [<<"INSERT">>], Duration),
case Result of
ok ->
instrument_tracer:set_status(ok),
ok;
{error, _} = Err ->
instrument_tracer:set_status(error),
Err
end
end).
get_order(OrderId) ->
instrument_tracer:with_span(<<"db_get_order">>, #{kind => client}, fun() ->
instrument_tracer:set_attributes(#{
<<"db.system">> => <<"postgresql">>,
<<"db.operation">> => <<"SELECT">>,
<<"db.table">> => <<"orders">>
}),
Start = erlang:monotonic_time(microsecond),
%% Simulated database query
Result = do_select(OrderId),
Duration = (erlang:monotonic_time(microsecond) - Start) / 1000000,
instrument_metric:inc_counter_vec(db_queries_total, [<<"SELECT">>]),
instrument_metric:observe_histogram_vec(db_query_duration_seconds, [<<"SELECT">>], Duration),
Result
end).
%% Simulated database operations
do_insert(_OrderId, _Order, _PaymentId) ->
timer:sleep(10), %% Simulate latency
ok.
do_select(_OrderId) ->
timer:sleep(5),
{ok, #{id => <<"test">>, items => [], total => 0}}.Payment Integration
%% src/order_service_payment.erl
-module(order_service_payment).
-export([charge/3]).
charge(OrderId, Amount, Order) ->
instrument_tracer:with_span(<<"payment_charge">>, #{kind => client}, fun() ->
instrument_tracer:set_attributes(#{
<<"payment.amount">> => Amount,
<<"payment.currency">> => <<"USD">>
}),
%% Inject trace context into outgoing request
Ctx = instrument_context:current(),
Headers = instrument_propagation:inject_headers(Ctx),
URL = get_payment_url(),
Body = jiffy:encode(#{
order_id => OrderId,
amount => Amount,
currency => <<"USD">>,
customer => maps:get(<<"customer">>, Order, #{})
}),
logger:debug("Calling payment service", #{url => URL}),
case hackney:request(post, URL, Headers, Body, [{recv_timeout, 30000}]) of
{ok, 200, _RespHeaders, ClientRef} ->
{ok, RespBody} = hackney:body(ClientRef),
Response = jiffy:decode(RespBody, [return_maps]),
PaymentId = maps:get(<<"payment_id">>, Response),
instrument_tracer:set_attributes(#{
<<"http.status_code">> => 200,
<<"payment.id">> => PaymentId
}),
instrument_tracer:set_status(ok),
{ok, PaymentId};
{ok, Status, _RespHeaders, ClientRef} ->
{ok, RespBody} = hackney:body(ClientRef),
logger:warning("Payment failed with status ~p: ~s", [Status, RespBody]),
instrument_tracer:set_attributes(#{
<<"http.status_code">> => Status
}),
instrument_tracer:set_status(error, <<"Payment declined">>),
{error, declined};
{error, Reason} ->
logger:error("Payment service error: ~p", [Reason]),
instrument_tracer:record_exception(Reason),
instrument_tracer:set_status(error, <<"Payment service unavailable">>),
{error, service_unavailable}
end
end).
get_payment_url() ->
BaseUrl = application:get_env(order_service, payment_service_url, "http://localhost:8081"),
BaseUrl ++ "/charge".Running the Service
# Start dependencies
docker-compose up -d jaeger prometheus
# Set environment
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
export OTEL_SERVICE_NAME=order-service
export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.5
# Run the service
rebar3 shell
Testing
# Create an order
curl -X POST http://localhost:8080/orders \
-H "Content-Type: application/json" \
-d '{"items": [{"name": "Widget", "price": 29.99, "quantity": 2}]}'
# Get metrics
curl http://localhost:8080/metrics
# View traces
open http://localhost:16686
What You Built
You now have a service that includes:
- Request tracing across HTTP, database, and payment calls
- Distributed trace propagation to external services
- Metrics for requests, latencies, and business events
- Correlated logs with trace context
- Prometheus metrics endpoint
- OTLP export to Jaeger
Next Steps
- Add more business metrics specific to your domain
- Implement custom sampling based on your needs
- Set up alerting on key metrics
- Create dashboards in Grafana
- Add SLO monitoring
You now have the core patterns you need to instrument real Erlang services. From here, the work is mostly about choosing the right measurements for your own domain.