The project so far makes it possible to scan photos in a directory, run them through various vision models and store the details in a database. Using Aspire instead of Docker Compose for local development as I would usually do has been fun so far.
Aspire 9.0 has handy new features, that are interesting for the next stage of the project. This post will summarise them with some examples.
As the next stage in the project is evaluating the performance of the models I am using, at this point I needed to make Jupyter Notebooks easily accessible inside my codebase and development environment. With .Net Aspire 9.0 this becomes a convenient process.
Getting Ready for Next Steps
Given we have several options from open source when it comes to computer vision models go generate photo summaries, we need to be able to evaluate the results from these models to be able to choose one that suits our domain.
One workflow to do this effectively is using Jupyter Notebooks where we can retrieve our results from the database and then compare with results obtained from commercial models.
Introducing Jupyter Notebooks that runs on a remote host to our Project means the following would be important:
- Containers are running on remote host so we need to be able to include the notebooks in version control
- However docker volumes are on a remote host, no easy way to copy them
- Also we need container to container connection Jupyter Server to MongoDB both of which are running on a remote server and Jupyter Server needs to be able to speak to MongoDb.
As we will see below, .NET Aspire 9.0 takes care of these.
Here is the list of features in .Net Aspire 9.0 that are relevant to this project and will be covered in this post
- Tooling
- No longer relying on workloads: Now we can set up .Net Aspire using packages and project templates.
- Templates can also be installed as following:
dotnet new install Aspire.ProjectTemplates::9.0.0
- Dashboard and UX
- Managing Resource Lifecycles: Start, Stop, Restart from the dashboard.
- Browser Telemetry Support.
- App Host (Orchestration)
- Waiting for dependencies.
- Resource health Checks
- Persistent Containers
- Resource commands
- Container networking
- One from .Net 9.0
- Enabling DI registration of metrics using IMeterFactory
Browser Telemetry Support
Earlier on, I was curious how to integrate traces from the front end and see the distributed traces. .Net Aspire 9.0 brings an out of the box way for this as below.
It is important to remember that Open Telemetry client instrumentation on the browser is experimental.
'
DOTNET_DASHBOARD_OTLP_ENDPOINT_URL to the .Net applications."OTEL_EXPORTER_OTLP_PROTOCOL","http/protobuf"With these in place, I was able to follow Microsoft examples to make it work on a Stencil Js application.
...
//https://www.honeycomb.io/blog/opentelemetry-browser-instrumentation
//https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation-xml-http-request
//https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/web/opentelemetry-instrumentation-user-interaction
//https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/web/opentelemetry-instrumentation-long-task
//https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/web/opentelemetry-instrumentation-long-task
export default async () => {
const otlpOptions = { omitted };
const attributes = { omitted }
provider.addSpanProcessor(new SimpleSpanProcessor(new OTLPTraceExporter(otlpOptions)));
provider.register({contextManager: new StackContextManager()});
registerInstrumentations({
instrumentations: [
getWebAutoInstrumentations({
'@opentelemetry/instrumentation-xml-http-request': {
clearTimingResources: true,
}
}),
new LongTaskInstrumentation({
observerCallback: (span, longtaskEvent) => {
span.setAttribute('location.pathname', window.location.pathname)
}
}),
new FetchInstrumentation({
propagateTraceHeaderCorsUrls: [new RegExp('\\/api\\/*')],
ignoreUrls: [new RegExp('\\/tile\\/*')],
})],
});
...
};
With the changes above and the existing setup in our backend, we can see the end to end traces below. The use case below is for selecting a model and then seeing a request to extract summaries from all 50 images in the db. We can see the durations of db calls, calls to inference endpoints as well as transport and our backend components as they all been triggered by the ui.
Defining Resource Dependencies
Once we have defined our health checks, we can declare our dependencies so that our applications will not start before services they might depend on at start up.
Prior to .NET Aspire 9.0, it was possible to achieve this following an example provided by David Fowler in Issue #921 in Aspire repository as linked below.
So now, once all the additional code deleted, all we need is to use .WaitFor(resource) from the framework as below:
var apiService = builder.AddProject<Projects.PhotoSearch_API>("apiservice")
.WithReference(ollamaContainer)
.WithReference(mongodb)
.WithReference(messaging)
.WithReference(openai)
.WaitFor(ollamaContainer)
.WaitFor(mongodb)
.WaitFor(messaging);
This ensures all dependencies spin up first and become healthy and then the application will start. This also helps in cases where the containers need to download data on first run, which might take several minutes.
Persistent Containers
In this project we have some containers that take time to lad and get ready such as OSM Map Tile Server, Nominatim container for reverse geocoding and Ollama is starting the first time as it needs to download the model.
So if we make code changes, containers would stop and then we would need to wait for all dependencies again.
This is another area where Aspire 9.0 comes to the rescue with a single method call as below:
var nominatimResourceBuilder = builder.AddResource(nominatimResource)
.WithLifetime(ContainerLifetime.Persistent)
... other calls omitted;
With this in place, we can start debugging stop and start again lightning fast without having to wait for the containers.
Resource Commands
Resource commands allow the developers register commands that can be accessed from the Aspire Dashboard against a resource.
As I have added Jupyter Notebooks container to the project this weekend, command have helped sole one problem when running the Jupyter Server on a remote Docker host: How do we manage the notebook files? Ideally we manage them in the same repository.
With a download and upload command, we can upload the notebook from our local drive where our git repository is and then we can download the model when modified inside the Jupyter Notebook container.
Downloading the notebook in regular intervals will be one of the next steps.
There is not much to do besides ensuring the connection string are injected as below:
builder.AddJupyter("jupyter", !string.IsNullOrWhiteSpace(dockerHost), "secret",portMappings["JupyterPort"].PublicPort)
.WithReference(mongodb);
Once that is done, we can access the connection string in python as following: connection_string = os.environ.get('ConnectionStrings__photo-search')
Custom Metrics via Dependency Injection
This is not aspire but as a colleague pointed last week on elf the features in .Net 9.0 was out of the box ability to use Dependency Injection (DI) for registering consuming metrics.
We can achieve this by injecting IMeterFactory to a utility class where we manage our meters.
Here is an example from this project for measuring number of models summarised as well as durations for each image:
using System.Diagnostics.Metrics;
namespace PhotoSearch.ServiceDefaults;
public class ConsoleMetrics
{
private readonly Counter<int> _photosSummariesCounter;
private readonly Histogram<double> _photosSummaryHistogram;
public ConsoleMetrics(IMeterFactory meterFactory)
{
var meter = meterFactory.Create("PhotoSummary.Worker");
_photosSummariesCounter = meter.CreateCounter<int>("photosummary.summary.generated");
_photosSummaryHistogram = meter.CreateHistogram<double>("photosummary.summary.durationseconds");
}
public void PhotoSummarised(string model, int quantity)
{
_photosSummariesCounter.Add(quantity,
new KeyValuePair<string, object?>("photosummary.summary.model", model));
}
public void PhotoSummaryTiming(string model,string photo, double durationSeconds)
{
_photosSummaryHistogram.Record(durationSeconds,
new KeyValuePair<string, object?>("photosummary.summary.model", model),
new KeyValuePair<string, object?>("photosummary.summary.photo", photo));
}
}
// Register:
builder.Services.AddSingleton<ConsoleMetrics>();
// Ensure our meter is added when configuring OpenTelemetry:
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddMeter(InstrumentationOptions.MeterName);
// other calls
})
// Inject and use:
builder.Services.AddSingleton<ConsoleMetrics>();
As we see below, total 70 images summarised using two models so far.
If we investigate the traces, the results are interesting.
To summarise a photo, currently there are 3 calls to Ollama container:
- Get the overall summary
- Using the context, get a list of objects
- Again using the context so far, get a list of possible categories
And the traces show us, we spent 4 minute 27 seconds waiting for categories to be generated.
This is worth investigation and as we have a notebook, it is also easier to experiment with the same prompt / image combinations.
SOCIAL SHARE CARD GENERATOR