Skip to content

Instantly share code, notes, and snippets.

@noahfalk
Last active March 20, 2023 13:07
Show Gist options
  • Save noahfalk/85668c0e820e13650f9e344aafeba135 to your computer and use it in GitHub Desktop.
Save noahfalk/85668c0e820e13650f9e344aafeba135 to your computer and use it in GitHub Desktop.

Disclaimer: This is very much a work in progress and I haven't worked through the details or fretted over naming. The goal is to get feedback on the basics and iterate. Perhaps we'll find it is fatally flawed, but we got to start somewhere :)

Use cases

I think there are likely two different schools of thought for how people will want to use these APIs:

  1. Ad-hoc usage - These are folks who want to add some metrics to their code with minimal effort.
  • Localized code changes only, ideally within a single class in a single file
  • Add no new types
  • Minimize LoC required, simple to follow best practice
  • Easy to iterate and change in the future
  • Easy to learn, not much abstraction
  • Easily unit testable
  • Not bothered by mixing telemetry schema/metadata definition and usage in their code
  1. De-coupled usage - These are folks who want a strong boundary between defining the instrumentation schema/metadata and its usage.
  • Strongly typed API that defines available instrumentation
  • Configuration of names/descriptions/units/versions/tags centralized and separated from usage
  • Instrumentation can be easily registered and shared via DI
  • Easily unit testable
  • Accept a modest increase in abstractions/LoC/files touched in order to achieve the decoupling

My hope is to have a single API surface that can reasonably satisfy both use-cases depending on the pattern chosen, and that it shouldn't be too hard to switch between the patterns or mix and match within the same project as needs change.

Ad-hoc pattern

public class FruitStoreController
{
    Counter<int> _fruitSold;
    ObservableGauge<int> _ordersOutstanding;
  
    public FruitStoreController(IMeterFactory meterFactory, IOrderService orders)
    {
        Meter m = meterFactory.CreateMeter<FruitStoreController>(); // alternately you could specify a string name
        _fruitSold = m.CreateCounter<int>("fruit-sold", "Amount of fruit sold at our store");
        _ordersOutstanding = m.CreateObservableCounter<int>("orders-outstanding", orders.GetOutstanding, "Orders created and not yet shipped");
    }
  
    public IActionResult PlaceOrder(int fruitCount)
    {
        _fruitSold.Add(fruitCount);
        ...
    }
}

Decoupled Pattern

This one consists of three parts: defining the instrumentation, registering the instrumentation, and using the instrumentation

Define instrumentation

class FruitStoreInstrumentation
{
    public FruitStoreInstrumentation(IMeterFactory factory, IOrderService orders)
    {
        Meter m = meterFactory.CreateMeter("FruitCo.FruitStore"); // alternately you could specify a type name
                                                                  // but if this instrumentation is shared across several
                                                                  // components then there may not be a single type name which makes sense
        FruitSold = m.CreateCounter<int>("fruit-sold", "Amount of fruit sold at our store");
        OutstandingOrders = m.CreateObservableCounter<int>(orders-outstanding", orders.GetOutstanding, "Orders created and not yet shipped");
    }

    public Counter<int> FruitSold { get; init }
    public ObservableCounter<int> OutstandingOrders { get; init; }
}

Register instrumentation

services.AddSingleton<FruitStoreInstrumentation>();

Use instrumentation

public class FruitStoreController
{
    FruitStoreInstrumentation _instrumentation;
  
    public FruitStoreController(FruitStoreInstrumentation instrumentation)
    {
        _instrumentation = instrumentation;
    }
  
    public IActionResult PlaceOrder(int fruitCount)
    {
        _instrumentation.FruitSold.Add(fruitCount);
        ...
    }
}

How to test

[Fact]
public void OrderIncreasesFruitSold()
{
    // arrange
    using meterFactory = new MeterFactory();
    IOrderService orderService = new FakeOrderService();
    FruitStoreController controller = new FruitStoreController(meterFactory, orderService);
    using InstrumentRecorder<int> recorder = new InstrumentRecorder(meterFactory, typeof(FruitStoreController), "fruits-sold");
    
    // act
    controller.PlaceOrder(4);
    controller.PlaceOrder(18);
    
    // assert
    Assert.Equal(recorder.Measurements.Count, 2);
    Assert.Equal(recorder.Measurements[0], 4);
    Assert.Equal(recorder.Measurements[1], 18);
}

[Fact]
public void OutstandingOrdersReportsOrderServiceValue()
{
    // arrange
    using meterFactory = new MeterFactory();
    IOrderService orderService = new FakeOrderService();
    FruitStoreController controller = new FruitStoreController(meterFactory, orderService);
    using InstrumentRecorder<int> recorder = new InstrumentRecorder(meterFactory, typeof(FruitStoreController), "fruits-sold");
    
    // act
    // this takes a snapshot of the current value
    // we could do things that change the value and take multiple snapshots if necessary
    recorder.SnapshotObservableInstrument();
    
    // assert
    Assert.Equal(recorder.Measurements.Count, 1);
    Assert.Equal(recorder.Measurements[0], 49);
}

class FakeOrderService : IOrderService
{
    public int GetOutstandingOrders() => 49;
}

Different overloads of the InstrumentRecorder constructor are possible depending on what information is easily available:

  1. factory + Meter name + instrument name
  2. Meter reference + instrument name
  3. Instrument reference

In the decoupled case where we directly expose the instrument as part of the API the instrument references are probably easier to use. With the APIs from .NET 6 it is possible to implement (2) and (3), but we'd need something new as part of this feature if we want to identify a Meter by factory+name.

New API/behavior needed

  1. Create MeterFactory and IMeterFactory. The factory would need to cache any Meters it creates and Dispose them when the factory is disposed. It also needs to return pre-existing Meters when given the same name+version.
  2. We probably want an extension method on ServiceCollection to add the factory and call it by default from hosts, similar to logging.
  3. All the Meter.CreateXXX APIs need to start returning cached instruments when given the same argument values
  4. We need some support for a MeterListener to receive publish notifications for Meters from a certain factory rather than from all Meters
  5. The InstrumentRecorder either needs to be implemented in some assembly or it could be a small code snippet we document and people paste it into their tests.

What about interfaces?

This design does not add any interfaces for Meters or Instruments on the premise that they aren't needed to accomplish what devs want to do.

  1. For creating test mocks - the unit test above provides an easy alternative that requires no mocks.
  2. For capturing and transmitting metric measurements during production - use MeterListener to receive the data.
  3. For changing any other aspect of Meter/Instrument API behavior - I've never heard anyone ask and its probably by-design that you can't, but if anyone thinks there is an important scenario here being missed lmk.

Composing with other future features

Strongly typed instruments (dotnet/runtime#77516)

Either of the patterns could use more strongly typed instruments in place of the current ones.

Target-typed or named DI capabilities

Meter could be injected directly rather than MeterFactory if a good name can be automatically inferred. This eliminates needing to use a MeterFactory abstraction as part of the Meter/Instrument definition process, saves 1 LoC, and may improve naming consistency. However I am not confident that the enclosing type name will always be a good name so I expect the documentation will still need to show users how to use the MeterFactory to get a different name when needed. Subjectively I think this represents a modest improvement to the overall scenario but others felt this difference represented a more substantial improvement.

I have no opposition to either of these DI features but nor do the patterns above feel in critical need of them.

Telemetry from multiple DI containers

Today if you want to view the telemetry from outside the process then a FruitStore meter from DI container A looks identical to one in container B. Two approaches that might useful:

  • Similar to the InstrumentRecorder that was bound to a particular factory during testing, any telemetry library could scope itself to the Meters that came from one factory rather than all Meters in the process. This lets folks set up distinct pipelines per-container and similar to what OTel does for logging.
  • Customizing the IMeterFactory could allow tagging Meters with some container specific data that allows them to disambiguated later. This would also rely on Meters themselves gaining some functionality to store tags. Today tags are only defined on individual measurements, rather than the broader instrument or meter scopes.

Other forms of instrumentation

I deliberately didn't name the FruitStoreInstrumentation using the word Metrics. I think it would be perfectly reasonable for developers to put other instrumentation objects such as ILogger, ActivitySource, DiagnosticSource, etc in the same containing type. In the ad-hoc case I expect other instrumentation objects would be stored as fields in the controller (or whatever class is producing the data).

@julealgon
Copy link

Sorry I know that probably wasn't what you were aiming for...

It definitely wasn't as I wished folks from inside the .NET team would also be interested in solving long-standing workarounds and cleanup the overall DI architecture. The fact that these are still not seen as workarounds at all is baffling to me, and very unfortunate since it basically means we not only will not fix it, but will perpetuate the bad patterns with more and more usages (like this one) making it even harder to fix in the future.

I appreciate the guidance regardless @noahfalk . I'll consider starting an issue in the DI space, but from what I'm seeing in this discussion, I doubt it will go very far.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment