背景

Opentelemetry-cpp 是可观测领域,opentelemetry (CNCF基金会孵化项目)的C++ SDK接入层opentelemetry 里面主要是分链路跟踪(Trace)、指标(Metrics)、日志(Logs)三大块。 同时 opentelemetry 有一个标准规范文档 opentelemetry-specification ,而SDK实现主要就是来对这个标准规范文档的特定语言实现。 由于日志(Logs)这一块一直处于Experimental阶段,所以很长时间以来 C++ SDK接入层 都没有及时更新跟进规范的变化。

去年底的时候,我抽时间来更新了一波规范实现。但是拖到今天才来写这篇分享( * T _ T * ),也是想着把一些向前不兼容的变化列举出来,方便可能有些同学升级的时候可能会碰到一些问题可以对照解决。

首先简单介绍一下 opentelemetry-cpp 的内部模块结构,主要是三部分: API, SDK, Exporter 。然后还有其他一些扩展和辅助性功能。

  • API模块: 主要用于各类组件接入创建链路跟踪(Trace)、指标(Metrics)、日志(Logs),这部分必须是Head-Only的,ABI稳定的。
  • SDK模块: 主要用于应用框架层来接入如何实际产生和处理数据的实现层,和API模块搭配可以做出类似热插拔的效果。
  • Exporter模块: 决定如何导出数据,用什么协议导出。
  • 其他: 包含HTTP封装,插件模块等周边支持组件

不兼容变更(Break changes)

Logs的规范 层面,大部分的变化只是名称上的变化,还有少量的功能性增强和一些仍然处于非常早期的新功能草案。

命名变更

首先,规范定义了一个 LogRecord 类型,并且要求在API组件中实现的导出接口全部以这个 LogRecord 为基准,LogRecord 要求实现所有字段的 setter 接口。 同时,导出接口 从原来的 Logger::Log(...) 重命名为了 Logger::EmitLogRecord(...) ,并且在继承关系中,仅需要实现 Logger::EmitLogRecord(nostd::unique_ptr<LogRecord>)

这部分的变化,由于原先的 SDK模块中也有个 LogRecord 结构,这里其实是不同含义,但是重名了。所以我们废弃了 SDK中的 LogRecord 。 新的规范中在SDK里也加入了 ReadableLogRecordReadWriteLogRecord 两个接口,要求 ReadableLogRecord 必须实现 getter 接口,而 ReadWriteLogRecord setter 和 getter 都有。 其实这里的 ReadWriteLogRecord 就很像之前 SDK 里的 LogRecord 。 另外由于SDK里所有模块的数据传递都是通过各自内部的一个叫 Recordable 的对象,并且这个对象还会包含一些SDK内的独有的内容,比如Resource和InstrumentationScope。 所以我们最终的继承关系改成了如下所示:

classDiagram
    LogRecord <|-- Recordable
    Recordable <|-- ReadableLogRecord
    Recordable <|-- 特定Exporter的Recordable
    ReadableLogRecord <|-- ReadWriteLogRecord
    LogRecord: +SetTimestamp()
    LogRecord: +SetObservedTimestamp()
    LogRecord: +SetSeverity()
    LogRecord: +SetBody()
    LogRecord: +SetAttribute()
    LogRecord: +SetTraceId()
    LogRecord: +SetSpanId()
    LogRecord: +SetTraceFlags()
    class Recordable {
      +SetResource()
      +SetInstrumentationScope()
    }
    class ReadableLogRecord {
        GetTimestamp()
        GetObservedTimestamp()
        GetSeverity()
        GetSeverityText()
        GetBody()
        GetTraceId()
        GetSpanId()
        GetTraceFlags()
        GetAttributes()
        GetResource()
        GetInstrumentationScope()
    }
    class 特定Exporter的Recordable {
        位于Exporter中,比如 OtlpLogRecordable
    }

另外所有的 Processor、Exporter、Factory的名字都由 *Log*Processor*Log*Exporter*Log*Factory 变为了 *LogRecord*Processor*LogRecord*Exporter*LogRecord*Factory 。 对应的头文件的文件名也做了相应修改。

对于 Processor 接口, OnReceive 重命名为了 OnEmit

我们还移除了老 Logger::Log(...) 接口中的 name 字段。在非常古老的版本中,Logs的协议里有 name 字段,所以 Logger::Log(...) 接口里也有个 name 参数。 但是实际上很早期的一次协议更新就已经移除这个字段了(从v1.4.0版本开始),我们在接口层保留了相当长的时间,并且设置为了 deprecated 就是为了给用户一段时间去迁移。 现在这次改造就顺带移除了这个废弃的接口,以防误用。

API接口中一些STL类型的接口换成了 nostd 的版本,比如 std::unique_ptr -> nostd::unique_ptr 。这部分主要是和其他模块保持一致,管理ABI兼容性。

新增功能

规范变化也包含一些新功能,比如说,我们现在可以通过 LoggerProvider::GetLogger() 接口来设置自动从链路跟踪(Trace)模块的 RuntimeContext 中自动提取和关联当前的调用链路信息。 这样可以把日志自动关联到链路上,当然这需要使用链路跟踪(Trace)模块的模块启用里面的 Scope 组件。我们仍然支持手动设置链路信息。

Logs的数据字段方面,增加了一个 observed_timestamp ,这个字段会默认填写为上报创建 LogRecord 时的时间。当然也是可以手动设置的。

我们现在也允许在 LoggerProvider::GetLogger() 创建Logger的时候指定InstrumentationScope的属性,不过目前的版本中只是接口合入了,下个Release才会真正进入到Exporter的数据中。 详见: https://github.com/open-telemetry/opentelemetry-cpp/pull/2004 这个PR的合入进展。

新的规范有一个还处于非常初期的 EventLog 的草案,主要是包装了一下普通版本的 Logger ,在要设置log的关联事件的时候规范化 event.nameevent.domain 的使用。这个可能未来还会变化比较大,暂时不推荐使用。

由于新增了一些属性,Logs的一键调用接口(包括 EmitLogRecord, Debug, Error, Info 等)的参数越多了。并且大部分都是可选的,这对接口的维护带来了极大得不变。为了缓解这个问题,我用type traits的方式重构了这部分的重载。相当于把原来的按类型+参数顺序的重载改成了仅仅按类型的重载,无所谓传参的顺序。然后根据参数类型来决定如何填充字段。比如参考其中一个接口注释:

/**
  * Emit a Log Record object with arguments
  *
  * @param log_record Log record
  * @tparam args Arguments which can be used to set data of log record by type.
  *  Severity                                -> severity, severity_text
  *  string_view                             -> body
  *  AttributeValue                          -> body
  *  SpanContext                             -> span_id,tace_id and trace_flags
  *  SpanId                                  -> span_id
  *  TraceId                                 -> tace_id
  *  TraceFlags                              -> trace_flags
  *  SystemTimestamp                         -> timestamp
  *  system_clock::time_point                -> timestamp
  *  KeyValueIterable                        -> attributes
  *  Key value iterable container            -> attributes
  *  span<pair<string_view, AttributeValue>> -> attributes(return type of MakeAttributes)
  */
template <class... ArgumentType>
void EmitLogRecord(nostd::unique_ptr<LogRecord> &&log_record, ArgumentType &&... args);

这上面列出了如何按参数类型会影响哪些字段。那么如何实现呢?首先由于传入的参数 ArgumentType 是个通用引用,它既能匹配左值引用,又能匹配右值,还能匹配是否带 constviolate ,所以第一层模板类型提取我们可以用 std::decay<ArgumentType>::type 来移除这些类型修饰。然后通过一个特殊类型的特化来实现不同的功能,参数传递走完美转发就可以了。比如:

template <>
struct LogRecordSetterTrait<trace::SpanContext>
{
  template <class ArgumentType>
  inline static LogRecord *Set(LogRecord *log_record, ArgumentType &&arg) noexcept
  {
    log_record->SetSpanId(arg.span_id());
    log_record->SetTraceId(arg.trace_id());
    log_record->SetTraceFlags(arg.trace_flags());

    return log_record;
  }
};

template <>
struct LogRecordSetterTrait<trace::SpanId>
{
  template <class ArgumentType>
  inline static LogRecord *Set(LogRecord *log_record, ArgumentType &&arg) noexcept
  {
    log_record->SetSpanId(std::forward<ArgumentType>(arg));

    return log_record;
  }
};

可以看到,如果参数类型是 trace::SpanContext 我们会同时设置 trace_id 、 span_id 和 trace_flags 。但是如果传入的是 trace::SpanId 我们就只会设置 trace_id

这里还有一处和之前不太兼容的特殊的地方,就是通用引用(Universal Reference),就是 ArgumentType && 不能匹配 std::initializer_list<T> 。而我们之前是可以通过 std::initializer_list<std::pair<nostd::string_view, common::AttributeValue>> 来传递attributes的。 这里为了适配,我们提供了一组 MakeAttributes(...) 接口,通过重载来适配attributes的传递。整个实现大概是这样:

inline static nostd::span<const std::pair<nostd::string_view, common::AttributeValue>>
MakeAttributes(std::initializer_list<std::pair<nostd::string_view, common::AttributeValue>>
                   attributes) noexcept
{
  return nostd::span<const std::pair<nostd::string_view, common::AttributeValue>>{
      attributes.begin(), attributes.end()};
}

inline static nostd::span<const std::pair<nostd::string_view, common::AttributeValue>>
MakeAttributes(
    nostd::span<const std::pair<nostd::string_view, common::AttributeValue>> attributes) noexcept
{
  return attributes;
}

inline static const common::KeyValueIterable &MakeAttributes(
    const common::KeyValueIterable &attributes) noexcept
{
  return attributes;
}

template <
    class ArgumentType,
    nostd::enable_if_t<common::detail::is_key_value_iterable<ArgumentType>::value> * = nullptr>
inline static common::KeyValueIterableView<ArgumentType> MakeAttributes(
    const ArgumentType &arg) noexcept
{
  return common::KeyValueIterableView<ArgumentType>(arg);
}

使用起来可以类似这种 logger->EmitLogRecord(Severity::kError, MakeAttributes({ {"key1", "value 1"}, {"key2", 2} }));logger->Debug("Test log message", MakeAttributes(std::map<std::string, std::string>())); 。应该也不会造成太大困扰。

总结

主要的改动也就这么多,也欢迎有兴趣的小伙伴们互相交流。 如有遗漏,也欢迎小伙伴们指出和补充。