Putting It All Together
View SourceThis chapter brings together everything you have learned to build a complete instrumented service.
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_tracer:register_exporter(
fun(Span) -> instrument_exporter_console:export(Span) end
);
Endpoint ->
%% Production: batch OTLP export
{ok, _} = instrument_span_processor_batch:start_link(#{
exporter => instrument_exporter_otlp:new(#{
endpoint => Endpoint ++ "/v1/traces"
}),
max_queue_size => 2048,
scheduled_delay => 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 with:
- Request tracing across HTTP and database 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
Congratulations on completing this handbook!