Once the OddDotNet OpenTelemetry Test Harness has received an Export{SIGNAL}ServiceRequest from your application (where SIGNAL is either Trace - see note, Metric, or Log), the next step is to query the harness to confirm that the correct signals have been received.

NOTE: OddDotNet uses Trace and Span interchangeably under most circumstances. However, since the return type of SpanQueryService is a Span and not a full Trace, Span is used instead. The name of the request to ingest a trace/span is ExportTraceServiceRequest, and the name of the service used to query an individual span is SpanQueryService. Under most circumstances, you will not see the Export{SIGNAL}ServiceRequest, so the SIGNAL values for {SIGNAL}QueryService are Span, Metric, and Log.

Signal Caching

All signals are cached by OddDotNet for 30 seconds. A background service is responsible for cleaning up “expired” signals on a 1-second interval.

The purpose of signal caching is to allow for querying of signals that may have already been received by OddDotNet before the query begins. For example:

// generate some telemetry data as part of a request
await SomeWorkflowThatGeneratesSignals();

// Signals are generated asynchronously, but some time may pass before the query begins
await Task.Delay(TimeSpan.FromSeconds(1));

// Now query for the signal
var response = await client.QueryAsync(request);

Without signal caching, the above code would miss any signals that came in during the 1 second delay between generating the signals and querying for those signals.

When a query is made, the previous 30 seconds of signals are checked in cache first to see if any matches are found. If they are, they are included in the results. The query then continues to check more signals as they come in, up to the Take and Duration values specified.

Both the signal expiration and the cleanup interval are configurable, although care should be taken when modifying these values as they can have a direct impact on performance. See ODD_CACHE_CLEANUP_INTERVAL and ODD_CACHE_EXPIRATION for details.

{SIGNAL}QueryService

The {SIGNAL}QueryService handles queries for the specified signal (Span, Metric, Log). Queries can be made using gRPC or (eventually) HTTP requests. The {SIGNAL}QueryRequest is used for every query related to a signal, and each request returns a {SIGNAL}QueryResponse.

{SIGNAL}QueryResponse

Every query for a signal will return a {SIGNAL}QueryResponse message. The structure of that message is (using Span):

message SpanQueryResponse {
  repeated FlatSpan spans = 1;
}

message FlatSpan {
    opentelemetry.proto.trace.v1.Span span = 1;
    opentelemetry.proto.resource.v1.Resource resource = 2;
    opentelemetry.proto.common.v1.InstrumentationScope instrumentation_scope = 3;
    string resource_schema_url = 4;
    string instrumentation_scope_schema_url = 5;
}

The signal is de-normalized or “flattened”, hence the Flat{SIGNAL} name. The OpenTelemetry data structure for a signal includes the resource that created the signal and the instrumentation library that generated the signal. To avoid traversing this data structure as part of OddDotNet testing, the resource and the scope are de-normalized.

The list of signals could be empty if no signals match the filters you provide within the timeout specified.

{SIGNAL}QueryRequest

The SIGNALQueryRequest object is a simple message that encapsulates all the query language needed to make a request.

message SpanQueryRequest {
  repeated Where filters = 1;
  odddotnet.proto.common.v1.Take take = 2;
  optional odddotnet.proto.common.v1.Duration duration = 3;
}

message MetricQueryRequest {
  repeated Where filters = 1;
  odddotnet.proto.common.v1.Take take = 2;
  optional odddotnet.proto.common.v1.Duration duration = 3;
}

message LogQueryRequest {
  repeated Where filters = 1;
  odddotnet.proto.common.v1.Take take = 2;
  optional odddotnet.proto.common.v1.Duration duration = 3;
}

Take

The Take property supports three options. Take is optional, so if no value is supplied, the query will default to TakeFirst.

message Take {
  oneof value {
    TakeFirst takeFirst = 1;
    TakeAll takeAll = 2;
    TakeExact takeExact = 3;
  }
}

message TakeFirst {}

message TakeAll {}

message TakeExact {
  int32 count = 1;
}

TakeFirst finds the first matching signal based on the filters you provide. The query will return immediately upon finding a match, with the matching signal contained in the {SIGNAL}QueryResponse message.

TakeAll will continue to add signals that match your filters until the request times out.

Finally, TakeExact has a single property count that instructs the test harness to continue to match signals until it has reached the number specified in count.

Duration

Duration specifies how long to wait after the query has started before returning the results.

message Duration {
  int32 milliseconds = 1;
}

Duration is optional. If a value is not supplied, the query will continue to match spans until the Take amount has been met. When supplying a duration, the value is in milliseconds.

If a duration value is supplied, then the query will return either when the Take criteria has been met, or when the Duration is met, whichever is first. This means, for example, that a query with a TakeExact(100) might not return all 100 if matches are not found within the Duration.

Where

The filters property of the request defines the criteria used to match a signal. For the list of SIGNAL-specific filters, see the corresponding query page:

Here is the Where definition for Metrics, as an example:

message Where {
    oneof value {
        PropertyFilter property = 1;
        OrFilter or = 2;
        odddotnet.proto.common.v1.InstrumentationScopeFilter instrumentation_scope = 3;
        odddotnet.proto.resource.v1.ResourceFilter resource = 4;
        odddotnet.proto.common.v1.StringProperty instrumentation_scope_schema_url = 5;
        odddotnet.proto.common.v1.StringProperty resource_schema_url = 6;
    }
}

Each filter has logic that evaluates to either true or false. If a filter is true, the signal currently being checked passes that filter and moves on to the next filter in line. If all filters pass, the span is considered a match and is included in the results.

Each signal contains the same values that can be queried:

  • PropertyFilter - the properties available are specific to each signal
  • OrFilter
  • InstrumentationScope - for querying details related to the instrumentation library used to create the signal
  • Resource - for querying details related to the resource that created the signal
  • InstrumentationScopeSchemaUrl
  • ResourceSchemaUrl

PropertyFilter

The PropertyFilter provides the ability to check a specific property of a signal using the correct data type and a comparison that makes sense for that property.

Notice that the PropertyFilter contains a map of possible values that are either additional Filter types or some data type Property. When querying a signal, the goal is to build a query that reduces down to a single property that is being queried. Take metrics as an example.

The PropertyFilter of the MetricQueryRequest.Where includes a filter for Gauge:

message PropertyFilter {
    reserved 4, 6, 8;
    oneof value {
        ...
        GaugeFilter gauge = 5;
        ...
    }
}

Because Gauge is a GaugeFilter and not a GaugeProperty, this indicates that there are additional levels that can be queried. Here’s the GaugeFilter:

message GaugeFilter {
    oneof value {
        NumberDataPointFilter data_point = 1;
    }
}

Once again, the only property of the GaugeFilter is itself another filter, the NumberDataPointFilter. This filter includes a UInt64Property called StartTimeUnixNano.

message NumberDataPointFilter {
    reserved 1;
    oneof value {
        ...
        odddotnet.proto.common.v1.UInt64Property start_time_unix_nano = 2;
        ...
    }
}

To query for this property with a value of 123, the request would look like the following:

{
  "filters": [
    {
      "property": {
        "gauge": {
          "dataPoint": {
            "startTimeUnixNano": {
              "compareAs": "NUMBER_COMPARE_AS_TYPE_EQUALS",
              "compare": 123
            }
          }
        }
      }
    }
  ]
}

OrFilter

When you are adding a PropertyFilter to the request, each filter is added as an “AND”. This is not always desireable, as you may want to check if property ‘x’ matches “OR” property ‘y’ matches. The OrFilter provides this ability.

message OrFilter {
  repeated Where filters = 1;
}

The “OR” filter is just a list of filters, and it returns true if any of the filters within return true.

Generic Filters

Each signal contains various filters that are generic to all signals.

InstrumentationScopeFilter

All signals are generated using an instrumentation library. The InstrumentationScopeFilter allows for querying properties related to the instrumentation scope.

message InstrumentationScopeFilter {
  oneof value {
    odddotnet.proto.common.v1.StringProperty name = 1;
    odddotnet.proto.common.v1.KeyValueListProperty attributes = 2;
    odddotnet.proto.common.v1.StringProperty version = 3;
    odddotnet.proto.common.v1.UInt32Property dropped_attributes_count = 4;
  }
}
ResourceFilter

All signals are associated with a resource when they are created. This resource can be queried using the ResourceFilter.

message ResourceFilter {
  oneof value {
    odddotnet.proto.common.v1.KeyValueListProperty attributes = 1;
    odddotnet.proto.common.v1.UInt32Property dropped_attributes_count = 2;
  }
}

Generic Properties

The intent behind all filters is to narrow down your query to a specific property. Some properties are specific to a signal, such as the SpanKindProperty of a span. Most properties, however, are simple data types.

StringProperty

Allows for querying of string data types. The property expects the string to be compared and the comparison to perform.

message StringProperty {
  StringCompareAsType compare_as = 1;
  optional string compare = 2;
}

enum StringCompareAsType {
  STRING_COMPARE_AS_TYPE_NONE_UNSPECIFIED = 0;
  STRING_COMPARE_AS_TYPE_EQUALS = 1;
  STRING_COMPARE_AS_TYPE_NOT_EQUALS = 2;
  STRING_COMPARE_AS_TYPE_CONTAINS = 3;
  STRING_COMPARE_AS_TYPE_NOT_CONTAINS = 4;
  STRING_COMPARE_AS_TYPE_IS_EMPTY = 5;
  STRING_COMPARE_AS_TYPE_IS_NOT_EMPTY = 6;
}
ByteStringProperty

Similar to a StringProperty, allows for querying of ByteString data types such as traceId and spanId. Unlike strings, Contains and DoesNotContain are not included as ByteString comparisons are typically all or none.

message ByteStringProperty {
  ByteStringCompareAsType compare_as = 1;
  optional bytes compare = 2;
}

enum ByteStringCompareAsType {
  BYTE_STRING_COMPARE_AS_TYPE_NONE_UNSPECIFIED = 0;
  BYTE_STRING_COMPARE_AS_TYPE_EQUALS = 1;
  BYTE_STRING_COMPARE_AS_TYPE_NOT_EQUALS = 2;
  BYTE_STRING_COMPARE_AS_TYPE_EMPTY = 3;
  BYTE_STRING_COMPARE_AS_TYPE_NOT_EMPTY = 4;
}
BoolProperty

Allows for querying bool property types.

message BoolProperty {
  BoolCompareAsType compare_as = 1;
  optional bool compare = 2;
}

enum BoolCompareAsType {
  BOOL_COMPARE_AS_TYPE_NONE_UNSPECIFIED = 0;
  BOOL_COMPARE_AS_TYPE_EQUALS = 1;
  BOOL_COMPARE_AS_TYPE_NOT_EQUALS = 2;
}
Number Properties

The following numeric data types are supported: ulong|UInt64, uint|UInt32, long|Int64, int|Int32, and double. They all use the same NumberCompareAsType.

message UInt64Property {
  NumberCompareAsType compare_as = 1;
  optional fixed64 compare = 2;
}
  
message UInt32Property {
  NumberCompareAsType compare_as = 1;
  optional uint32 compare = 2;
}
  
message Int64Property {
  NumberCompareAsType compare_as = 1;
  optional int64 compare = 2;
}
  
message Int32Property {
  NumberCompareAsType compare_as = 1;
  optional int32 compare = 2;
}
  
message DoubleProperty {
  NumberCompareAsType compare_as = 1;
  optional double compare = 2;
}

enum NumberCompareAsType {
  NUMBER_COMPARE_AS_TYPE_NONE_UNSPECIFIED = 0;
  NUMBER_COMPARE_AS_TYPE_EQUALS = 1;
  NUMBER_COMPARE_AS_TYPE_NOT_EQUALS = 2;
  NUMBER_COMPARE_AS_TYPE_GREATER_THAN = 3;
  NUMBER_COMPARE_AS_TYPE_GREATER_THAN_EQUALS = 4;
  NUMBER_COMPARE_AS_TYPE_LESS_THAN = 5;
  NUMBER_COMPARE_AS_TYPE_LESS_THAN_EQUALS = 6;
}
Enum Properties

Signals contain enum types, such as the SpanKind enum property for spans, or the SeverityNumber of a log message. Each enum property contains the property being checked, and an EnumCompareAsType to define the comparison.

enum EnumCompareAsType {
  ENUM_COMPARE_AS_TYPE_NONE_UNSPECIFIED = 0;
  ENUM_COMPARE_AS_TYPE_EQUALS = 1;
  ENUM_COMPARE_AS_TYPE_NOT_EQUALS = 2;
}
Collections

Querying becomes significantly more complicated when dealing with collections. Collections are typically associated with Metadata of a metric, the Body of a log message, or the Attributes of any of the signals.

When querying a collection, you first specify that the data type of the property IS a collection. Let’s take Body as an example:

odddotnet.proto.common.v1.AnyValueProperty body = 5;

The Body of a log message is defined as an AnyValue. This message type is defined by OpenTelemetry as:

message AnyValue {
  oneof value {
    string string_value = 1;
    bool bool_value = 2;
    int64 int_value = 3;
    double double_value = 4;
    ArrayValue array_value = 5;
    KeyValueList kvlist_value = 6;
    bytes bytes_value = 7;
  }
}

So the Body property could be a collection of type ArrayValue or KeyValueList, but it could also just as well be a string instead. Therefore, you must first verify that the property you’re checking is a collection, and then you can filter on the contents of that collection. Data type mismatches, such as saying an AnyValue property is a string when it’s really a collection, will cause the filter to return false. Be sure to verify what data type your AnyValue property is so that the filter matches when found.

When performing this comparison, OddDotNet will very that each and every item you have listed in your filter exists in the signal being checked. In other words, performing ArrayValue and KeyValueList comparisons will check to confirm that the collection you have specified is a subset of the signal’s collection.

The following query will confirm that the Body of a log signal is a KeyValueList, and that it contains a KeyValue where the key is "test" and the value is an AnyValue with a double property equal to 123.0.

// Create a new Where filter
var filter = new Where
{
  Property = new PropertyFilter // We're interested in a property of the Log
  {
    Body = new AnyValueProperty // We're looking for the Body, which is an AnyValueProperty
    {
      KvlistValue = new KeyValueListProperty // We believe the data type of the Body is a KeyValueList
      {
        Values = // And we want to match if the following is present
        {
          new KeyValueProperty
          {
            Key = "test" // A KeyValue whose key is "test",
            Value = new AnyValueProperty
            {
              DoubleValue = new DoubleProperty // and whose Value is a Double...
              {
                CompareAs = NumberCompareAsType.Equals, // that is equal to...
                Compare = 123.0 // this double value
              }
            }
          }
        }
      }
    }
  }
}

Given the above filter, this log would match:

{
  "body": {
    "kvlistValue": {
      "values": [
        {
          "key": "test",
          "value": {
            "doubleValue": 123.0
          }
        }
      ]
    }
  }
}

This log would not match, because the body is the wrong type:

{
  "body": {
    "stringValue": "Some unstructured log message"
  }
}

This log also would not match, because the key is different:

{
  "body": {
    "kvlistValue": {
      "values": [
        {
          "key": "other",
          "value": {
            "doubleValue": 123.0
          }
        }
      ]
    }
  }
}

OddDotNet allows for nesting, so if you have a property that is, say, an ArrayValue inside of another ArrayValue, they will be handled appropriately.

AnyValueProperty

KeyValueProperty and ArrayValueProperty reference AnyValueProperty. As it’s name implies, properties of this type can hold one of a number of different data types.

message AnyValueProperty {
  oneof value {
    StringProperty string_value = 1;
    BoolProperty bool_value = 2;
    Int64Property int_value = 3;
    DoubleProperty double_value = 4;
    ArrayValueProperty array_value = 5;
    KeyValueListProperty kvlist_value = 6;
    ByteStringProperty byte_string_value = 7;
  }
}
ArrayValueProperty

These are simply repeated AnyValueProperty. Each item added to this property must exist in the signal property being checked.

message ArrayValueProperty {
  repeated AnyValueProperty values = 1;
}
KeyValue Properties

The same applies to a KeyValueListProperty: each item add to the filter’s list must exist in the signal being checked.

message KeyValueListProperty {
  repeated KeyValueProperty values = 1;
}

message KeyValueProperty {
  string key = 1;
  AnyValueProperty value = 2;
}