-
Notifications
You must be signed in to change notification settings - Fork 307
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Geneva.Logs] Support prefix-based table name mapping #796
[Geneva.Logs] Support prefix-based table name mapping #796
Conversation
Codecov Report
Additional details and impacted files@@ Coverage Diff @@
## main #796 +/- ##
==========================================
+ Coverage 77.88% 78.08% +0.20%
==========================================
Files 176 177 +1
Lines 5303 5379 +76
==========================================
+ Hits 4130 4200 +70
- Misses 1173 1179 +6
|
src/OpenTelemetry.Exporter.Geneva/Internal/TableNameSerializer.cs
Outdated
Show resolved
Hide resolved
} | ||
} | ||
|
||
// Note: This is using copy-on-write pattern to keep the happy |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mentioned above possibly using Hashtable
. Hashtable has a unique quirk in that it supports the many-readers-single-writer pattern. That would allow removing copy-on-write, which is great for the warm-up phase:
Method | Mean | Error | StdDev | Gen0 | Gen1 | Allocated |
---|---|---|---|---|---|---|
CopyOnWrite | 3,659.8 us | 63.39 us | 56.19 us | 1328.1250 | 183.5938 | 16326.84 KB |
Hashtable | 103.6 us | 1.95 us | 1.82 us | 13.4277 | 6.5918 | 165.3 KB |
Benchmark code
using System.Collections;
using System.Collections.Generic;
using BenchmarkDotNet.Attributes;
namespace OpenTelemetry.Exporter.Geneva.Benchmark;
[MemoryDiagnoser]
public class CopyOnWriteBenchmarks
{
private readonly object lockObject = new();
[Benchmark]
public Dictionary<string, byte[]> CopyOnWrite()
{
Dictionary<string, byte[]> d = new();
for (int i = 0; i < 1024; i++)
{
string k = $"Category{i}";
if (!d.TryGetValue(k, out var value))
{
lock (this.lockObject)
{
if (!d.TryGetValue(k, out value))
{
d[k] = new byte[1];
var old = d;
d = new(old);
}
}
}
}
return d;
}
[Benchmark]
public Hashtable Hashtable()
{
Hashtable ht = new();
for (int i = 0; i < 1024; i++)
{
string k = $"Category{i}";
var value = (string)ht[k];
if (value == null)
{
lock (this.lockObject)
{
value = (string)ht[k];
if (value == null)
{
ht[k] = new byte[1];
}
}
}
}
return ht;
}
}
Chatted with @utpilla we decided to not use Hashtable
for now because the docs also say it isn't recommended to be used.
It is an implementation detail so we can always change it in the future.
the value of the key to either the desired custom table name or `*` to enable | ||
"pass-through" mapping. For details on "pass-through" mode see below. | ||
|
||
For example, given the configuration... |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ThomsonTan please review this part and see if the agent can do the same in theory (in case folks use the OTLP path and want to achieve the same).
src/OpenTelemetry.Exporter.Geneva/Internal/TableNameSerializer.cs
Outdated
Show resolved
Hide resolved
|
||
string currentKey = null; | ||
|
||
foreach (var mapping in this.m_tableMappings) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can be optimized. We can avoid checking every key of the TableNameMappings
, if we keep a collection of the keys sorted in descending order and use that to decide if we need to keep checking for next keys.
Let's consider the example provided in the description. Let's say the category name was MyCompany.Product2.Component
and we have the keys sorted in descending order:
"MyPartner"
"MyCompany.Product2.Security"
"MyCompany.Product2"
"MyCompany.Product1"
"MyCompany"
"*"
Once we find a match we can break out of the loop as that would be the longest match. In this example the match would be MyCompany.Product2
which is the one we want.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've been working on this but it is getting kind of big with benchmarks, etc. Thinking I'll merge this PR and then open perf improvements as its own thing.
src/OpenTelemetry.Exporter.Geneva/Internal/TableNameSerializer.cs
Outdated
Show resolved
Hide resolved
? this.m_defaultTableName | ||
: s_passthroughTableName; | ||
|
||
if (mappedTableName.Length <= 0 && tableNameCache.CachedSanitizedTableNameCount < MaxCachedSanitizedTableNames) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why does this limit of 1024 matter? If I understand correctly, regardless of the CachedSanitizedTableNameCount
value, we would always add a new category as a key to the m_tableNameCache
. So why not just add the actual sanitized category name as the value instead of s_passthroughTableName
when the 1024 limit is reached.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Short answer: Today we don't cache any of the sanitized names so this was me being conservative.
Long answer:
If I understand correctly, regardless of the CachedSanitizedTableNameCount value, we would always add a new category as a key to the m_tableNameCache
Correct
So why not just add the actual sanitized category name as the value instead of s_passthroughTableName when the 1024 limit is reached.
Just to make it less greedy. Each category added after 1024 will grow by a pointer to s_passthroughTableName instead of a pointer to some freshly allocated buffer (up to 52 bytes IIRC).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just to make it less greedy. Each category added after 1024 will grow by a pointer to s_passthroughTableName instead of a pointer to some freshly allocated buffer (up to 52 bytes IIRC).
Would 52 bytes be considered high? For every new set of 1024 keys, it's only 52 KB max.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Honestly, didn't think too much about it. Just nice to prevent unconstrained growth where we can. The check is only processed the first time we see a category name value and then we cache the result. So it is pretty inexpensive (a few nanos) to have the cap. You want me to remove it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
More than the if
check I was thinking we could save on another round of parsing the Category name when we have more than 1024 category names.
It will come down to the cost of copying a small byte[]
to the buffer vs executing WriteSanitizedCategoryName (parsing + serialize). I was wondering if we anyway have to parse the string for dictionary lookup why not just get hold of the byte[]
instead of again parsing and serializing the sanitized Category name.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm. I think there is some confusion about what is going on. So we always cache something. For a given category name the cache will be one of these things...
- A defined mapping (pointer to something in m_tableMappings).
- The default table name (pointer to m_defaultTableName).
- A sanitized category name (allocation).
- Array.Empty if category name could not be sanitized (pointer to static).
- The pass-through indicator if the cache was full (pointer to s_passthroughTableName).
The logic is...
- Lookup the mapping using category name from the cache.
- If the mapping is the pass-through indicator, write out the sanitized table name directly to the buffer.
For pass-through today the logic is...
- Check for a mapping using category name.
- If nothing was found and pass-through is enabled, write out the sanitized table name directly to the buffer.
So basically when the cache fills up we don't do double-work, we just fallback to the behavior we have today.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Update... I synced up with @utpilla offline. The concern here is that in computing hash code for CategoryName in order to look into the cache "parses" the string. I have some ideas to improve this, but I don't thing we need to do anything here. The perf should be better for most, slightly worse for some.
-
For users using at least 1 mapping, there is already a hash code computation + dictionary lookup in their path today. What these users will see: There should be see a speed-up for <= 1024 categories. Same speed as today for > 1024 categories.
-
For users NOT using any mappings today, there is now a new hash code computation + dictionary lookup in their path. What these users will see: When using default table name, there should be a speed-up because we now copy a pre-built array using span helpers instead of calling into
MessagePackSerializer.SerializeAsciiString
each time. When using pass-through: Speed-up for <= 1024 categories. A slight slow-down for > 1024 categories.
src/OpenTelemetry.Exporter.Geneva/Internal/TableNameSerializer.cs
Outdated
Show resolved
Hide resolved
This PR was marked stale due to lack of activity. It will be closed in 7 days. |
Changes
This PR adds support for prefix-based resolution (similar to logging configuration) to the table mapping configuration options.
Usage
Given this config...
ILogger<ThirdParty.Thing>
: This would go to "DefaultLogs"ILogger<MyCompany.ProductX.Thing>
: This would go to "InternalLogs"ILogger<MyCompany.Product1.Thing>
: This would go to "InternalProduct1Logs"ILogger<MyCompany.Product2.Thing>
: This would go to "InternalProduct2Logs"ILogger<MyCompany.Product2.Security.Alert>
: This would go to "InternalSecurityLogs"ILogger<MyPartner.Product.Thing>
: This is marked as pass-through ("*") so it will be sanitized as "MyPartnerProductThing" table nameImplementation details
The implementation is close to what ILogger does for its filter configuration. The idea is you don't want to have to apply the rules each time you process a log message because the perf would be terrible. You want to have the result of rule selection computed once and available each time a log message is written. What the logging framework does is store the result on the
ILogger
instance created/injected from the factory. We don't have that luxury here. What I did was introduce a helper class which maintains a cache of the final category -> table name. It is backed by a dictionary so you pay for a hash on the lookup which is what we were paying before (if mappings were used at all).Some details about the cache...
Lookups are lock-free. If a new mapping is needed we do a copy-on-write. So warmup is expensive but once you are warm it is cheap.
There is no constraint on the number of keys (category names) which will be cached. ILogger itself maintains an unconstrained cache of loggers by category name so it seemed like that would OOM well before this.
If the mapping is pass-through we only cache up to 1024 of the sanitized table names. Once the max is reached we just write them out to the buffer as they are logged (this was the existing behavior always).
Benchmarks
Implementation + optimizations actually speed things up for everyone.
Before:
After:
TODOs
CHANGELOG.md
updated for non-trivial changes