visit
A system is said to be highly observable if its internal state can be inferred by studying its output at any point in time.
Logs: Record of individual events that make up the transaction.
Metrics: Record of aggregates of events that make up the transaction.
Traces: Record of the latency of operations to identify bottlenecks in the transaction.
Context comprises attributes that are common to a set of events. These are two types of contexts. Static context (or resource) defines the location of events. Their value doesn’t change after the application executable starts. Examples include the name or version of the service or the library name.
Dynamic context (or span) defines the active operation that contains the event. The value of span attributes changes when the operation executes. Some common examples of span attributes include the start time of a request, the HTTP response status code, or the path of the HTTP request.
In a distributed transaction, the context needs to be passed to all the associated services. In such cases, the receiver service uses the context to produce new spans. A trace that crosses the service boundary becomes a distributed trace and the process of transferring context to other services is called context propagation.
In the case of .NET, the OpenTelemetry implementation is based on the existing types in the System.Diagnostics.*
namespace as follows:
System.Diagnostics.ActivitySource
represents an OpenTelemetry tracer responsible for producing Spans
.
System.Diagnostics.Activity
represents a Span
.
You can add attributes to a span using the AddTag
function. Also, you can add baggage using the AddBaggage
function. The baggage is transported to the child activities, which might be available in other services using the W3C header.
POST /ems/billing
: Records the hours worked by an employee on a project.
GET /ems/billing/{employeeId}
: Fetches an employee’s hours on different projects.
POST /ems/payroll/add
: Adds an employee to payroll.
GET /ems/payroll/{employeeId}
: Fetches the payroll data for the employee.
However, by studying the dependencies, you can uncouple them with little effort.
The complete source code of the EMS application is available in.
docker run \
-e "ACCEPT_EULA=Y" \
-e "SA_PASSWORD=Str0ngPa$$w0rd" \
-p 1433:1433 \
--name monolith-db \
--hostname sql1 \
-d mcr.microsoft.com/mssql/server:2019-latest
IF NOT EXISTS(SELECT * FROM sys.databases WHERE name = 'EMSDb')
BEGIN
CREATE DATABASE EMSDb
END
GO
USE EMSDb
IF OBJECT_ID('[dbo].[Timekeeping]', 'U') IS NULL
BEGIN
CREATE TABLE [Timekeeping] (
[EmployeeId] INT NOT NULL,
[ProjectId] INT NOT NULL,
[WeekClosingDate] DATETIME NOT NULL,
[HoursWorked] INT NOT NULL,
CONSTRAINT [PK_Timekeeping] PRIMARY KEY CLUSTERED ([EmployeeId] ASC, [ProjectId] ASC, [WeekClosingDate] ASC)
)
END
GO
IF OBJECT_ID('[dbo].[Payroll]', 'U') IS NULL
BEGIN
CREATE TABLE [Payroll] (
[EmployeeId] INT NOT NULL,
[PayRateInUSD] MONEY DEFAULT 0 NOT NULL,
CONSTRAINT [PK_Payroll] PRIMARY KEY CLUSTERED ([EmployeeId] ASC)
)
END
GO
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped(_ =>
new SqlConnection(builder.Configuration.GetConnectionString("EmployeeDbConnectionString")));
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.MapPost("/ems/billing", async (Timekeeping timekeepingRecord, SqlConnection db) =>
{
await db.ExecuteAsync(
"INSERT INTO Timekeeping Values(@EmployeeId, @ProjectId, @WeekClosingDate, @HoursWorked)",
timekeepingRecord);
return Results.Created($"/ems/billing/{timekeepingRecord.EmployeeId}", timekeepingRecord);
})
.WithName("RecordProjectWork")
.Produces(StatusCodes.Status201Created);
app.MapGet("/ems/billing/{empId}/", async (int empId, SqlConnection db) =>
{
var result = await db.QueryAsync<Timekeeping>("SELECT * FROM Timekeeping WHERE EmployeeId=@empId", empId);
return result.Any() ? Results.Ok(result) : Results.NotFound();
})
.WithName("GetBillingDetails")
.Produces<IEnumerable<Timekeeping>>()
.Produces(StatusCodes.Status404NotFound);
app.MapPost("/ems/payroll/add/", async (Payroll payrollRecord, SqlConnection db) =>
{
await db.ExecuteAsync(
"INSERT INTO Payroll Values(@EmployeeId, @PayRateInUSD)", payrollRecord);
return Results.Created($"/ems/payroll/{payrollRecord.EmployeeId}", payrollRecord);
})
.WithName("AddEmployeeToPayroll")
.Produces(StatusCodes.Status201Created);
app.MapGet("/ems/payroll/{empId}", async (int empId, SqlConnection db) =>
{
var result = await db.QueryAsync<Payroll>("SELECT * FROM Payroll WHERE EmployeeId=@empId", empId);
return result.Any() ? Results.Ok(result) : Results.NotFound();
})
.WithName("GetEmployeePayroll")
.Produces<IEnumerable<Payroll>>()
.Produces(StatusCodes.Status404NotFound);
app.Run();
public class Timekeeping
{
public int EmployeeId { get; set; }
public int ProjectId { get; set; }
public DateTime WeekClosingDate { get; set; }
public int HoursWorked { get; set; }
}
public class Payroll
{
public int EmployeeId { get; set; }
public decimal PayRateInUSD { get; set; }
}
<PackageReference Include="OpenTelemetry" Version="1.2.0-rc2" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.2.0-rc2" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.0.0-rc9" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.0.0-rc9" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.0.0-rc9" />
<PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.0.0-rc9" />
The following piece of code instruments OpenTelemetry in our API. It will also instrument the SqlClient
to emit verbose telemetry. The telemetry from the SqlClient
is key to identifying the database dependencies in detail.
// Configure tracing
builder.Services.AddOpenTelemetryTracing(builder => builder
// Customize the traces gathered by the HTTP request handler
.AddAspNetCoreInstrumentation(options =>
{
// Only capture the spans generated from the ems/* endpoints
options.Filter = context => context.Request.Path.Value?.Contains("ems") ?? false;
options.RecordException = true;
// Add metadata for the request such as the HTTP method and response length
options.Enrich = (activity, eventName, rawObject) =>
{
switch (eventName)
{
case "OnStartActivity":
{
if (rawObject is not HttpRequest httpRequest)
{
return;
}
activity.SetTag("requestProtocol", httpRequest.Protocol);
activity.SetTag("requestMethod", httpRequest.Method);
break;
}
case "OnStopActivity":
{
if (rawObject is HttpResponse httpResponse)
{
activity.SetTag("responseLength", httpResponse.ContentLength);
}
break;
}
}
};
})
// Customize the telemetry generated by the SqlClient
.AddSqlClientInstrumentation(options =>
{
options.EnableConnectionLevelAttributes = true;
options.SetDbStatementForStoredProcedure = true;
options.SetDbStatementForText = true;
options.RecordException = true;
options.Enrich = (activity, x, y) => activity.SetTag("db.type", "sql");
})
.AddSource("my-corp.ems.ems-api")
// Create resources (key-value pairs) that describe your service such as service name and version
.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("ems-api")
.AddAttributes(new[] { new KeyValuePair<string, object>("service.version", "1.0.0.0") }))
// Ensures that all activities are recorded and sent to exporter
.SetSampler(new AlwaysOnSampler())
// Exports spans to Lightstep
.AddOtlpExporter(otlpOptions =>
{
otlpOptions.Endpoint = new Uri("//ingest.lightstep.com:443/traces/otlp/v0.9");
otlpOptions.Headers = $"lightstep-access-token={lsToken}";
otlpOptions.Protocol = OtlpExportProtocol.HttpProtobuf;
}));
var activitySource = new ActivitySource("my-corp.ems.ems-api");
app.MapPost("/ems/billing", async (Timekeeping timekeepingRecord, SqlConnection db) =>
{
using var activity = activitySource.StartActivity("Record project work", ActivityKind.Server);
activity?.AddEvent(new ActivityEvent("Project billed"));
activity?.SetTag(nameof(Timekeeping.EmployeeId), timekeepingRecord.EmployeeId);
activity?.SetTag(nameof(Timekeeping.ProjectId), timekeepingRecord.ProjectId);
activity?.SetTag(nameof(Timekeeping.WeekClosingDate), timekeepingRecord.WeekClosingDate);
await db.ExecuteAsync(
"INSERT INTO Timekeeping Values(@EmployeeId, @ProjectId, @WeekClosingDate, @HoursWorked)",
timekeepingRecord);
return Results.Created($"/ems/billing/{timekeepingRecord.EmployeeId}", timekeepingRecord);
})
.WithName("RecordProjectWork")
.Produces(StatusCodes.Status201Created);
We copy the token and paste it in the appsettings
file.
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"EmployeeDbConnectionString": "Server=localhost;Database=EMSDb;User Id=sa;Password=Str0ngPa$$w0rd;"
},
"LsToken": "<Lightstep token>"
}
Our application is now ready. We launch the application and send some requests to each endpoint. Here is an example of a request that I sent to the /ems/billing
endpoint. This request should create a record in the Timekeeping table of the database.'
Here’s another request I made to the /emp/payroll/add
endpoint to add a record to the Payroll table:
When we click on the /ems/payroll/add
operation, we can view the end-to-end trace. By viewing the spans, we can ascertain the sequence of operations for any request. Clicking on the spans brings up its events and attributes, from which we can gain deeper insights into the operation.
The final span visible in the trace is EMSDb, which was generated by our instrumented SQL Client. We click on the span to view its attributes and events as follows: