diff --git a/ext/ddtrace.c b/ext/ddtrace.c index cc78f7b477..dd81d580f7 100644 --- a/ext/ddtrace.c +++ b/ext/ddtrace.c @@ -625,6 +625,138 @@ static PHP_GSHUTDOWN_FUNCTION(ddtrace) { #endif } +static void dd_span_event_construct(ddtrace_span_event *event, zend_string *name, zend_long timestamp, zval *attributes) +{ + zval garbage_name, garbage_timestamp, garbage_attributes; + + // Copy current values to temporary zval variables + ZVAL_COPY_VALUE(&garbage_name, &event->property_name); + ZVAL_COPY_VALUE(&garbage_timestamp, &event->property_timestamp); + ZVAL_COPY_VALUE(&garbage_attributes, &event->property_attributes); + + ZVAL_STR_COPY(&event->property_name, name); + + // Use the provided timestamp or the current time in nanoseconds + if (timestamp == 0) { + struct timespec ts; + timespec_get(&ts, TIME_UTC); + timestamp = ts.tv_sec * ZEND_NANO_IN_SEC + ts.tv_nsec; + } + ZVAL_LONG(&event->property_timestamp, timestamp); + + // Initialize attributes + if (attributes) { + ZVAL_COPY(&event->property_attributes, attributes); + } else { + array_init(&event->property_attributes); + } + + // Free the copied values after replacement + zval_ptr_dtor(&garbage_name); + zval_ptr_dtor(&garbage_timestamp); + zval_ptr_dtor(&garbage_attributes); +} + +/* DDTrace\SpanEvent */ +zend_class_entry *ddtrace_ce_span_event; + +PHP_METHOD(DDTrace_SpanEvent, jsonSerialize) { + ddtrace_span_event *event = (ddtrace_span_event*)Z_OBJ_P(ZEND_THIS); + + zval array; + array_init(&array); + + Z_TRY_ADDREF(event->property_name); + add_assoc_zval_ex(&array, ZEND_STRL("name"), &event->property_name); + Z_TRY_ADDREF(event->property_timestamp); + add_assoc_zval_ex(&array, ZEND_STRL("time_unix_nano"), &event->property_timestamp); + + // Handle attributes dynamically + zval *attributes = &event->property_attributes; + zval combined_attributes; + array_init(&combined_attributes); + + if (instanceof_function(event->std.ce, ddtrace_ce_exception_span_event)) { + // Handle exception attributes dynamically if an exception property exists + ddtrace_exception_span_event *exception_event = (ddtrace_exception_span_event *) event; + zval *exception = &exception_event->property_exception; + if (Z_TYPE_P(exception) == IS_OBJECT && instanceof_function(Z_OBJCE_P(exception), zend_ce_throwable)) { + // Get exception message, type, and stack trace directly + zend_string *message = zai_exception_message(Z_OBJ_P(exception)); + if (ZSTR_LEN(message)) { + add_assoc_str_ex(&combined_attributes, ZEND_STRL("exception.message"), zend_string_copy(message)); + } + add_assoc_str_ex(&combined_attributes, ZEND_STRL("exception.type"), zend_string_copy(Z_OBJCE_P(exception)->name)); + + // Get the exception stack trace using zai_get_trace_without_args_from_exception + zend_string *stacktrace = zai_get_trace_without_args_from_exception(Z_OBJ_P(exception)); + add_assoc_str_ex(&combined_attributes, ZEND_STRL("exception.stacktrace"), stacktrace); + } + } + + if (Z_TYPE_P(attributes) == IS_ARRAY) { + zend_hash_copy(Z_ARRVAL(combined_attributes), Z_ARRVAL_P(attributes), (copy_ctor_func_t)zval_add_ref); + } + + if (zend_hash_num_elements(Z_ARRVAL(combined_attributes)) > 0) { + add_assoc_zval_ex(&array, ZEND_STRL("attributes"), &combined_attributes); + } else { + zval_ptr_dtor(&combined_attributes); // Clean up if no elements + } + + RETURN_ARR(Z_ARR(array)); // Return the array +} + +PHP_METHOD(DDTrace_SpanEvent, __construct) +{ + UNUSED(return_value); + + zend_string *name; + zval *attributes = NULL; + zend_long timestamp = 0; + + ZEND_PARSE_PARAMETERS_START(1, 3) + Z_PARAM_STR(name) + Z_PARAM_OPTIONAL + Z_PARAM_ARRAY_EX(attributes, 1, 0) + Z_PARAM_LONG(timestamp) + ZEND_PARSE_PARAMETERS_END(); + + ddtrace_span_event *event = (ddtrace_span_event*)Z_OBJ_P(ZEND_THIS); + + // Use the static function to set properties and handle cleanup + dd_span_event_construct(event, name, timestamp, attributes); +} + +/* DDTrace\ExceptionSpanEvent */ +zend_class_entry *ddtrace_ce_exception_span_event; + +PHP_METHOD(DDTrace_ExceptionSpanEvent, __construct) +{ + UNUSED(return_value); + + zval *exception; + zval *attributes = NULL; + + ZEND_PARSE_PARAMETERS_START(1, 2) + Z_PARAM_OBJECT_OF_CLASS(exception, zend_ce_throwable) + Z_PARAM_OPTIONAL + Z_PARAM_ARRAY_EX(attributes, 1, 0) + ZEND_PARSE_PARAMETERS_END(); + + ddtrace_exception_span_event *event = (ddtrace_exception_span_event*)Z_OBJ_P(ZEND_THIS); + + // Use the static function to set properties and handle cleanup + zend_string *name = zend_string_init(ZEND_STRL("exception"), 0); + dd_span_event_construct(&event->span_event, name, 0, attributes); + zend_string_release(name); + + zval garbage; + ZVAL_COPY_VALUE(&garbage, &event->property_exception); + ZVAL_COPY(&event->property_exception, exception); + zval_ptr_dtor(&garbage); +} + /* DDTrace\SpanLink */ zend_class_entry *ddtrace_ce_span_link; @@ -718,6 +850,7 @@ static zend_object *dd_init_span_data_object(zend_class_entry *class_type, ddtra array_init(&span->property_metrics); array_init(&span->property_meta_struct); array_init(&span->property_links); + array_init(&span->property_events); array_init(&span->property_peer_service_sources); #endif // Explicitly assign property-mapped NULLs @@ -1169,6 +1302,8 @@ static PHP_MINIT_FUNCTION(ddtrace) { dd_register_fatal_error_ce(); ddtrace_ce_integration = register_class_DDTrace_Integration(); ddtrace_ce_span_link = register_class_DDTrace_SpanLink(php_json_serializable_ce); + ddtrace_ce_span_event = register_class_DDTrace_SpanEvent(php_json_serializable_ce); + ddtrace_ce_exception_span_event = register_class_DDTrace_ExceptionSpanEvent(ddtrace_ce_span_event); ddtrace_ce_git_metadata = register_class_DDTrace_GitMetadata(); ddtrace_ce_git_metadata->create_object = ddtrace_git_metadata_create; memcpy(&ddtrace_git_metadata_handlers, &std_object_handlers, sizeof(zend_object_handlers)); diff --git a/ext/ddtrace.h b/ext/ddtrace.h index d6b8fb1d98..94f45127b6 100644 --- a/ext/ddtrace.h +++ b/ext/ddtrace.h @@ -23,6 +23,8 @@ extern zend_class_entry *ddtrace_ce_root_span_data; extern zend_class_entry *ddtrace_ce_span_stack; extern zend_class_entry *ddtrace_ce_fatal_error; extern zend_class_entry *ddtrace_ce_span_link; +extern zend_class_entry *ddtrace_ce_span_event; +extern zend_class_entry *ddtrace_ce_exception_span_event; extern zend_class_entry *ddtrace_ce_integration; extern zend_class_entry *ddtrace_ce_git_metadata; @@ -31,6 +33,8 @@ typedef struct ddtrace_span_data ddtrace_span_data; typedef struct ddtrace_root_span_data ddtrace_root_span_data; typedef struct ddtrace_span_stack ddtrace_span_stack; typedef struct ddtrace_span_link ddtrace_span_link; +typedef struct ddtrace_span_event ddtrace_span_event; +typedef struct ddtrace_exception_span_event ddtrace_exception_span_event; typedef struct ddtrace_git_metadata ddtrace_git_metadata; extern datadog_php_sapi ddtrace_active_sapi; diff --git a/ext/ddtrace.stub.php b/ext/ddtrace.stub.php index c2b6a4fd8a..f10d16dd39 100644 --- a/ext/ddtrace.stub.php +++ b/ext/ddtrace.stub.php @@ -23,6 +23,52 @@ */ const DBM_PROPAGATION_FULL = UNKNOWN; + class SpanEvent implements \JsonSerializable { + /** + * SpanEvent constructor. + * + * @param string $name The event name. + * @param int|null $timestamp The event start time in nanoseconds, if not provided set the current Unix timestamp. + * @param array $attributes Optional attributes for the event. + */ + public function __construct(string $name, array $attributes = [], ?int $timestamp = null) {} + + /** + * @var string The event name + */ + public string $name; + + /** + * @var string[] $attributes + */ + public array $attributes; + + /** + * @var int The event start time in nanoseconds, if not provided set the current Unix timestamp + */ + public int $timestamp; + + /** + * @return mixed + */ + public function jsonSerialize(): mixed {} + } + + class ExceptionSpanEvent extends SpanEvent { + /** + * ExceptionSpanEvent constructor. + * + * @param \Throwable $exception exception to record. + * @param array $attributes Optional attributes for the event. + */ + public function __construct(\Throwable $exception, array $attributes = []) {} + + /** + * @var \Throwable + */ + public \Throwable $exception; + } + class SpanLink implements \JsonSerializable { /** * @var string $traceId A 32-character, lower-case hexadecimal encoded string of the linked trace ID. This field @@ -144,6 +190,11 @@ class SpanData { */ public array $links = []; + /** + * @var SpanEvent[] $spanEvents An array of span events + */ + public array $events = []; + /** * @var string[] $peerServiceSources A sorted list of tag names used to set the `peer.service` tag. If a tag * name is added to this field and the tag exists on the span at serialization time, then the value of the tag diff --git a/ext/ddtrace_arginfo.h b/ext/ddtrace_arginfo.h index d0d048e706..6ef23c7e21 100644 --- a/ext/ddtrace_arginfo.h +++ b/ext/ddtrace_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: b7ca444d39b9a8489e4e93042e0f7e7eb9aa8b05 */ + * Stub hash: fa4bda312fa3b405b09e09c6bc81a05d2a8e3372 */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_trace_method, 0, 3, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, className, IS_STRING, 0) @@ -271,9 +271,22 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_dd_trace_synchronous_flush, 0, 0 ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, timeout, IS_LONG, 0, "100") ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_DDTrace_SpanLink_jsonSerialize, 0, 0, IS_MIXED, 0) +ZEND_BEGIN_ARG_INFO_EX(arginfo_class_DDTrace_SpanEvent___construct, 0, 0, 1) + ZEND_ARG_TYPE_INFO(0, name, IS_STRING, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, attributes, IS_ARRAY, 0, "[]") + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, timestamp, IS_LONG, 1, "null") ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_DDTrace_SpanEvent_jsonSerialize, 0, 0, IS_MIXED, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_INFO_EX(arginfo_class_DDTrace_ExceptionSpanEvent___construct, 0, 0, 1) + ZEND_ARG_OBJ_INFO(0, exception, Throwable, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, attributes, IS_ARRAY, 0, "[]") +ZEND_END_ARG_INFO() + +#define arginfo_class_DDTrace_SpanLink_jsonSerialize arginfo_class_DDTrace_SpanEvent_jsonSerialize + ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_DDTrace_SpanLink_fromHeaders, 0, 1, DDTrace\\SpanLink, 0) ZEND_ARG_TYPE_MASK(0, headersOrCallback, MAY_BE_ARRAY|MAY_BE_CALLABLE, NULL) ZEND_END_ARG_INFO() @@ -361,6 +374,9 @@ ZEND_FUNCTION(DDTrace_trace_function); ZEND_FUNCTION(DDTrace_trace_method); ZEND_FUNCTION(dd_untrace); ZEND_FUNCTION(dd_trace_synchronous_flush); +ZEND_METHOD(DDTrace_SpanEvent, __construct); +ZEND_METHOD(DDTrace_SpanEvent, jsonSerialize); +ZEND_METHOD(DDTrace_ExceptionSpanEvent, __construct); ZEND_METHOD(DDTrace_SpanLink, jsonSerialize); ZEND_METHOD(DDTrace_SpanLink, fromHeaders); ZEND_METHOD(DDTrace_SpanData, getDuration); @@ -444,6 +460,17 @@ static const zend_function_entry ext_functions[] = { ZEND_FE_END }; +static const zend_function_entry class_DDTrace_SpanEvent_methods[] = { + ZEND_ME(DDTrace_SpanEvent, __construct, arginfo_class_DDTrace_SpanEvent___construct, ZEND_ACC_PUBLIC) + ZEND_ME(DDTrace_SpanEvent, jsonSerialize, arginfo_class_DDTrace_SpanEvent_jsonSerialize, ZEND_ACC_PUBLIC) + ZEND_FE_END +}; + +static const zend_function_entry class_DDTrace_ExceptionSpanEvent_methods[] = { + ZEND_ME(DDTrace_ExceptionSpanEvent, __construct, arginfo_class_DDTrace_ExceptionSpanEvent___construct, ZEND_ACC_PUBLIC) + ZEND_FE_END +}; + static const zend_function_entry class_DDTrace_SpanLink_methods[] = { ZEND_ME(DDTrace_SpanLink, jsonSerialize, arginfo_class_DDTrace_SpanLink_jsonSerialize, ZEND_ACC_PUBLIC) ZEND_ME(DDTrace_SpanLink, fromHeaders, arginfo_class_DDTrace_SpanLink_fromHeaders, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) @@ -491,6 +518,52 @@ static void register_ddtrace_symbols(int module_number) REGISTER_LONG_CONSTANT("DD_TRACE_PRIORITY_SAMPLING_UNSET", DDTRACE_PRIORITY_SAMPLING_UNSET, CONST_PERSISTENT); } +static zend_class_entry *register_class_DDTrace_SpanEvent(zend_class_entry *class_entry_JsonSerializable) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "DDTrace", "SpanEvent", class_DDTrace_SpanEvent_methods); + class_entry = zend_register_internal_class_ex(&ce, NULL); + zend_class_implements(class_entry, 1, class_entry_JsonSerializable); + + zval property_name_default_value; + ZVAL_UNDEF(&property_name_default_value); + zend_string *property_name_name = zend_string_init("name", sizeof("name") - 1, 1); + zend_declare_typed_property(class_entry, property_name_name, &property_name_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); + zend_string_release(property_name_name); + + zval property_attributes_default_value; + ZVAL_UNDEF(&property_attributes_default_value); + zend_string *property_attributes_name = zend_string_init("attributes", sizeof("attributes") - 1, 1); + zend_declare_typed_property(class_entry, property_attributes_name, &property_attributes_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_ARRAY)); + zend_string_release(property_attributes_name); + + zval property_timestamp_default_value; + ZVAL_UNDEF(&property_timestamp_default_value); + zend_string *property_timestamp_name = zend_string_init("timestamp", sizeof("timestamp") - 1, 1); + zend_declare_typed_property(class_entry, property_timestamp_name, &property_timestamp_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG)); + zend_string_release(property_timestamp_name); + + return class_entry; +} + +static zend_class_entry *register_class_DDTrace_ExceptionSpanEvent(zend_class_entry *class_entry_DDTrace_SpanEvent) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "DDTrace", "ExceptionSpanEvent", class_DDTrace_ExceptionSpanEvent_methods); + class_entry = zend_register_internal_class_ex(&ce, class_entry_DDTrace_SpanEvent); + + zval property_exception_default_value; + ZVAL_UNDEF(&property_exception_default_value); + zend_string *property_exception_name = zend_string_init("exception", sizeof("exception") - 1, 1); + zend_string *property_exception_class_Throwable = zend_string_init("Throwable", sizeof("Throwable")-1, 1); + zend_declare_typed_property(class_entry, property_exception_name, &property_exception_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_CLASS(property_exception_class_Throwable, 0, 0)); + zend_string_release(property_exception_name); + + return class_entry; +} + static zend_class_entry *register_class_DDTrace_SpanLink(zend_class_entry *class_entry_JsonSerializable) { zend_class_entry ce, *class_entry; @@ -634,6 +707,12 @@ static zend_class_entry *register_class_DDTrace_SpanData(void) zend_declare_typed_property(class_entry, property_links_name, &property_links_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_ARRAY)); zend_string_release(property_links_name); + zval property_events_default_value; + ZVAL_EMPTY_ARRAY(&property_events_default_value); + zend_string *property_events_name = zend_string_init("events", sizeof("events") - 1, 1); + zend_declare_typed_property(class_entry, property_events_name, &property_events_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_ARRAY)); + zend_string_release(property_events_name); + zval property_peerServiceSources_default_value; ZVAL_EMPTY_ARRAY(&property_peerServiceSources_default_value); zend_string *property_peerServiceSources_name = zend_string_init("peerServiceSources", sizeof("peerServiceSources") - 1, 1); diff --git a/ext/serializer.c b/ext/serializer.c index 279ed53fcd..7f97e61d38 100644 --- a/ext/serializer.c +++ b/ext/serializer.c @@ -1326,6 +1326,23 @@ static void _serialize_meta(zval *el, ddtrace_span_data *span) { EG(exception) = current_exception; } + zend_array *span_events = ddtrace_property_array(&span->property_events); + if (zend_hash_num_elements(span_events) > 0) { + // Save the current exception, if any, and clear it for php_json_encode_serializable_object not to fail + // and zend_call_function to actually call the jsonSerialize method + // Restored after span events are serialized + zend_object* current_exception = EG(exception); + EG(exception) = NULL; + + smart_str buf = {0}; + _dd_serialize_json(span_events, &buf, 0); + add_assoc_str(meta, "events", buf.s); + + // Restore the exception + EG(exception) = current_exception; + } + + zval *git_metadata = &span->root->property_git_metadata; if (git_metadata && Z_TYPE_P(git_metadata) == IS_OBJECT) { ddtrace_git_metadata *metadata = (ddtrace_git_metadata *)Z_OBJ_P(git_metadata); diff --git a/ext/span.h b/ext/span.h index cefb20e0a7..58eb14d57e 100644 --- a/ext/span.h +++ b/ext/span.h @@ -55,6 +55,7 @@ typedef union ddtrace_span_properties { zval property_id; }; zval property_links; + zval property_events; zval property_peer_service_sources; union { union ddtrace_span_properties *parent; @@ -171,6 +172,23 @@ struct ddtrace_span_link { }; }; +struct ddtrace_span_event { + union { + zend_object std; + struct { + char object_placeholder[sizeof(zend_object) - sizeof(zval)]; + zval property_name; + zval property_attributes; + zval property_timestamp; + }; + }; +}; + +struct ddtrace_exception_span_event { + ddtrace_span_event span_event; + zval property_exception; +}; + struct ddtrace_git_metadata { union { zend_object std; diff --git a/src/DDTrace/OpenTelemetry/Context.php b/src/DDTrace/OpenTelemetry/Context.php index d5aa42765c..44910695e9 100644 --- a/src/DDTrace/OpenTelemetry/Context.php +++ b/src/DDTrace/OpenTelemetry/Context.php @@ -189,6 +189,12 @@ private static function activateParent(?SpanData $currentSpan): ContextInterface $links[] = new SDK\Link($linkSpanContext, Attributes::create($spanLink->attributes ?? [])); } + // Check for span events + $events = []; + foreach ($currentSpan->events as $spanEvent) { + $events[] = new SDK\Event($spanEvent->name, (int)$spanEvent->timestamp, Attributes::create((array)$spanEvent->attributes ?? [])); + } + $OTelCurrentSpan = SDK\Span::startSpan( $currentSpan, API\SpanContext::create($currentTraceId, $currentSpanId, $traceFlags, $traceState), // $context @@ -201,6 +207,7 @@ private static function activateParent(?SpanData $currentSpan): ContextInterface [], // $attributesBuilder $links, // $links count($links), // $totalRecordedLinks + $events, //$events false // The span was created using the DD Api ); ObjectKVStore::put($currentSpan, 'otel_span', $OTelCurrentSpan); diff --git a/src/DDTrace/OpenTelemetry/Span.php b/src/DDTrace/OpenTelemetry/Span.php index e459c8b8a0..52779a8775 100644 --- a/src/DDTrace/OpenTelemetry/Span.php +++ b/src/DDTrace/OpenTelemetry/Span.php @@ -6,6 +6,8 @@ use DDTrace\SpanData; use DDTrace\SpanLink; +use DDTrace\SpanEvent; +use DDTrace\ExceptionSpanEvent; use DDTrace\Tag; use DDTrace\OpenTelemetry\Convention; use DDTrace\Util\ObjectKVStore; @@ -46,6 +48,16 @@ final class Span extends API\Span implements ReadWriteSpanInterface /** @readonly */ private int $totalRecordedLinks; + /** + * @readonly + * + * @var list + */ + private array $events; + + /** @readonly */ + private int $totalRecordedEvents; + /** @readonly */ private int $kind; @@ -69,6 +81,7 @@ private function __construct( ResourceInfo $resource, array $links = [], int $totalRecordedLinks = 0, + array $events = [], bool $isRemapped = true ) { $this->span = $span; @@ -80,6 +93,7 @@ private function __construct( $this->resource = $resource; $this->links = $links; $this->totalRecordedLinks = $totalRecordedLinks; + $this->events = $events; $this->status = StatusData::unset(); @@ -91,7 +105,7 @@ private function __construct( $span->name = $this->operationNameConvention = Convention::defaultOperationName($span); } - // Set the span links + // Set the span links and events if ($isRemapped) { // At initialization time (now), only set the links if the span was created using the OTel API // Otherwise, the links were already set in DD's OpenTelemetry\Context\Context @@ -110,6 +124,20 @@ private function __construct( ObjectKVStore::put($spanLink, "link", $link); $span->links[] = $spanLink; } + + foreach ($events as $event) { + /** @var EventInterface $event */ + + $spanEvent = new SpanEvent( + $event->getName(), + $event->getAttributes()->toArray(), + $event->getEpochNanos() + ); + + // Save the event + ObjectKVStore::put($spanEvent, "event", $event); + $span->events[] = $spanEvent; + } } } @@ -136,6 +164,7 @@ public static function startSpan( array $attributes, array $links, int $totalRecordedLinks, + array $events, bool $isRemapped = true // Answers the question "Was the span created using the OTel API?" ): self { self::_setAttributes($span, $attributes); @@ -156,6 +185,7 @@ public static function startSpan( $resource, $links, $totalRecordedLinks, + $events, $isRemapped ); @@ -203,12 +233,13 @@ public function toSpanData(): SpanDataInterface $hasEnded = $this->hasEnded(); $this->updateSpanLinks(); + $this->updateSpanEvents(); return new ImmutableSpan( $this, $this->getName(), $this->links, - [], // TODO: Handle Span Events + $this->events, Attributes::create(array_merge($this->span->meta, $this->span->metrics)), 0, StatusData::create($this->status->getCode(), $this->status->getDescription()), @@ -253,7 +284,7 @@ public function getTotalRecordedLinks(): int public function getTotalRecordedEvents(): int { - return 0; + return $this->totalRecordedEvents; } /** @@ -332,7 +363,14 @@ public function setAttributes(iterable $attributes): SpanInterface */ public function addEvent(string $name, iterable $attributes = [], int $timestamp = null): SpanInterface { - // no-op + if (!$this->hasEnded()) { + $this->span->events[] = new SpanEvent( + $name, + $attributes, + $timestamp ?? (int)(microtime(true) * 1e9) + ); + } + return $this; } @@ -342,9 +380,13 @@ public function addEvent(string $name, iterable $attributes = [], int $timestamp public function recordException(Throwable $exception, iterable $attributes = []): SpanInterface { if (!$this->hasEnded()) { - $this->span->meta[Tag::ERROR_MSG] = $exception->getMessage(); - $this->span->meta[Tag::ERROR_TYPE] = get_class($exception); - $this->span->meta[Tag::ERROR_STACK] = $exception->getTraceAsString(); + // Update span metadata based on exception stack + $this->setAttribute(Tag::ERROR_STACK, \DDTrace\get_sanitized_exception_trace($exception)); + + $this->span->events[] = new ExceptionSpanEvent( + $exception, + $attributes + ); } return $this; @@ -475,4 +517,39 @@ private function updateSpanLinks() $this->links = $otel; $this->totalRecordedLinks = count($otel); } + + private function updateSpanEvents() + { + $datadogSpanEvents = $this->span->events; + $this->span->meta["events"] = count($this->events); + + $otel = []; + foreach ($datadogSpanEvents as $datadogSpanEvent) { + $exceptionAttributes = []; + $event = ObjectKVStore::get($datadogSpanEvent, "event"); + if ($event === null) { + if ($datadogSpanEvent instanceof ExceptionSpanEvent) { + // Standardized exception attributes + $exceptionAttributes = [ + 'exception.message' => $attributes['exception.message'] ?? $datadogSpanEvent->exception->getMessage(), + 'exception.type' => $attributes['exception.type'] ?? get_class($datadogSpanEvent->exception), + 'exception.stacktrace' => $attributes['exception.stacktrace'] ?? \DDTrace\get_sanitized_exception_trace($datadogSpanEvent->exception) + ]; + } + $event = new Event( + $datadogSpanEvent->name, + (int)$datadogSpanEvent->timestamp, + Attributes::create(array_merge($exceptionAttributes, \is_array($datadogSpanEvent->attributes) ? $datadogSpanEvent->attributes : iterator_to_array($datadogSpanEvent->attributes))) + ); + + // Save the event + ObjectKVStore::put($datadogSpanEvent, "event", $event); + } + $otel[] = $event; + } + + // Update the events + $this->events = $otel; + $this->totalRecordedEvents = count($otel); + } } diff --git a/src/DDTrace/OpenTelemetry/SpanBuilder.php b/src/DDTrace/OpenTelemetry/SpanBuilder.php index 29f1a92438..c6937ab36d 100644 --- a/src/DDTrace/OpenTelemetry/SpanBuilder.php +++ b/src/DDTrace/OpenTelemetry/SpanBuilder.php @@ -17,6 +17,7 @@ use OpenTelemetry\SDK\Common\Attribute\AttributesFactory; use OpenTelemetry\SDK\Common\Instrumentation\InstrumentationScopeInterface; use OpenTelemetry\SDK\Resource\ResourceInfoFactory; +use Throwable; final class SpanBuilder implements API\SpanBuilderInterface { @@ -43,6 +44,9 @@ final class SpanBuilder implements API\SpanBuilderInterface /** @var list */ private array $links = []; + /** @var list */ + private array $events = []; + /** @var array */ private array $attributes; @@ -92,6 +96,42 @@ public function addLink(SpanContextInterface $context, iterable $attributes = [] return $this; } + public function addEvent(string $name, iterable $attributes = [], int $timestamp = null): SpanBuilderInterface + { + $this->events[] = new Event( + $name, + $timestamp ?? (int)(microtime(true) * 1e9), + $this->tracerSharedState + ->getSpanLimits() + ->getEventAttributesFactory() + ->builder($attributes) + ->build(), + ); + + return $this; + } + + public function recordException(Throwable $exception, iterable $attributes = []): SpanBuilderInterface + { + // Standardized exception attributes + $exceptionAttributes = [ + 'exception.message' => $attributes['exception.message'] ?? $exception->getMessage(), + 'exception.type' => $attributes['exception.type'] ?? get_class($exception), + 'exception.stacktrace' => $attributes['exception.stacktrace'] ?? \DDTrace\get_sanitized_exception_trace($exception), + ]; + + // Update span metadata based on exception stack + $this->setAttribute(Tag::ERROR_STACK, $exceptionAttributes['exception.stacktrace']); + + // Merge additional attributes + $allAttributes = array_merge($exceptionAttributes, \is_array($attributes) ? $attributes : iterator_to_array($attributes)); + + // Record the exception event + $this->addEvent('exception', $allAttributes); + + return $this; + } + /** @inheritDoc */ public function setAttribute(string $key, $value): API\SpanBuilderInterface { @@ -156,6 +196,7 @@ public function startSpan(): SpanInterface $this->spanKind, Attributes::create($this->attributes), $this->links, + $this->events ); $span = $span ?? \DDTrace\start_trace_span($this->startEpochNanos); @@ -204,6 +245,7 @@ public function startSpan(): SpanInterface $this->attributes, $this->links, $this->totalNumberOfLinksAdded, + $this->events ); } diff --git a/tests/OpenTelemetry/Integration/API/TracerTest.php b/tests/OpenTelemetry/Integration/API/TracerTest.php index b7aa1d6ed8..4cc6da8c37 100644 --- a/tests/OpenTelemetry/Integration/API/TracerTest.php +++ b/tests/OpenTelemetry/Integration/API/TracerTest.php @@ -433,12 +433,7 @@ public function testRecordException() $span = $traces[0][0]; $this->assertSame('internal', $span['name']); $this->assertSame('test.span', $span['resource']); - $this->assertSame('exception message', $span['meta'][Tag::ERROR_MSG]); - $this->assertSame('RuntimeException', $span['meta'][Tag::ERROR_TYPE]); $this->assertNotEmpty($span['meta'][Tag::ERROR_STACK]); - $this->assertEquals(1, $span['error']); - - $this->markTestIncomplete("Span Events aren't yet supported"); } public function testSpanNameUpdate() diff --git a/tests/OpenTelemetry/Integration/InteroperabilityTest.php b/tests/OpenTelemetry/Integration/InteroperabilityTest.php index 0a7eacc767..6de97dbdc0 100644 --- a/tests/OpenTelemetry/Integration/InteroperabilityTest.php +++ b/tests/OpenTelemetry/Integration/InteroperabilityTest.php @@ -3,6 +3,8 @@ namespace DDTrace\Tests\OpenTelemetry\Integration; use DDTrace\SpanLink; +use DDTrace\SpanEvent; +use DDTrace\ExceptionSpanEvent; use DDTrace\Tag; use DDTrace\Tests\Common\BaseTestCase; use DDTrace\Tests\Common\SpanAssertion; @@ -1175,4 +1177,149 @@ public function testSpanLinksInteroperabilityAddDuplicates() $this->assertNotSame($otelSpanLinks[1], $otelSpanLinks[3]); }); } + + public function testSpanEventsInteroperabilityFromDatadogSpan() + { + $traces = $this->isolateTracer(function () { + $span = start_span(); + $span->name = "dd.span"; + + $spanEvent = new SpanEvent( + "event-name", + [ + 'arg1' => 'value1', + 'int_array' => [3, 4], + 'string_array' => ["5", "6"] + ], + 1720037568765201300 + ); + $span->events[] = $spanEvent; + + /** @var en $OTelSpan */ + $otelSpan = Span::getCurrent(); + $otelSpanEvent = $otelSpan->toSpanData()->getEvents()[0]; + + $this->assertSame('event-name', $otelSpanEvent->getName()); + $this->assertSame([ + 'arg1' => 'value1', + 'int_array' => [3, 4], + 'string_array' => ["5", "6"] + ], $otelSpanEvent->getAttributes()->toArray()); + $this->assertSame(1720037568765201300, (int)$otelSpanEvent->getEpochNanos()); + + close_span(); + }); + + $this->assertCount(1, $traces[0]); + $this->assertSame("[{\"name\":\"event-name\",\"time_unix_nano\":1720037568765201300,\"attributes\":{\"arg1\":\"value1\",\"int_array\":[3,4],\"string_array\":[\"5\",\"6\"]}}]", $traces[0][0]['meta']['events']); + } + + public function testSpanEventsInteroperabilityFromOpenTelemetrySpan() + { + $traces = $this->isolateTracer(function () { + $otelSpan = self::getTracer()->spanBuilder("otel.span") + ->startSpan(); + $otelSpan->addEvent( + "event-name", + [ + 'arg1' => 'value1', + 'int_array' => [3, 4], + 'string_array' => ["5", "6"] + ], + 1720037568765201300 + ); + + $activeSpan = active_span(); + $spanEvent = $activeSpan->events[0]; + $this->assertSame("event-name", $spanEvent->name); + $this->assertSame([ + 'arg1' => 'value1', + 'int_array' => [3, 4], + 'string_array' => ["5", "6"] + ], $spanEvent->attributes); + $this->assertSame(1720037568765201300, (int)$spanEvent->timestamp); + + $otelSpan->end(); + }); + + $this->assertCount(1, $traces[0]); + $this->assertSame("[{\"name\":\"event-name\",\"time_unix_nano\":1720037568765201300,\"attributes\":{\"arg1\":\"value1\",\"int_array\":[3,4],\"string_array\":[\"5\",\"6\"]}}]", $traces[0][0]['meta']['events']); + } + + public function testOtelRecordExceptionAttributesSerialization() + { + $lastException = new \Exception("woof3"); + + $traces = $this->isolateTracer(function () use ($lastException) { + $otelSpan = self::getTracer()->spanBuilder("operation") + ->recordException(new \Exception("woof1"), [ + "string_val" => "value", + "exception.stacktrace" => "stacktrace1" + ]) + ->startSpan(); + + $otelSpan->addEvent("non_exception_event", ["exception.stacktrace" => "non-error"]); + $otelSpan->recordException($lastException, ["exception.message" => "message override"]); + + $otelSpan->end(); + }); + + $events = json_decode($traces[0][0]['meta']['events'], true); + $this->assertCount(3, $events); + + $event1 = $events[0]; + $this->assertSame('value', $event1['attributes']['string_val']); + $this->assertSame('woof1', $event1['attributes']['exception.message']); + $this->assertSame('stacktrace1', $event1['attributes']['exception.stacktrace']); + + $event2 = $events[1]; + $this->assertSame('non-error', $event2['attributes']['exception.stacktrace']); + + $event3 = $events[2]; + $this->assertSame('message override', $event3['attributes']['exception.message']); + + $this->assertSame(\DDTrace\get_sanitized_exception_trace($lastException), $traces[0][0]['meta']['error.stack']); + + $this->assertArrayNotHasKey('error.message', $traces[0][0]['meta']); + $this->assertArrayNotHasKey('error.type', $traces[0][0]['meta']); + $this->assertArrayNotHasKey('error', $traces[0][0]); + } + + public function testExceptionSpanEvents() + { + $traces = $this->isolateTracer(function () { + $span = start_span(); + $span->name = "dd.span"; + + $spanEvent = new ExceptionSpanEvent( + new \Exception("Test exception message"), + [ + 'arg1' => 'value1', + 'exception.stacktrace' => 'Stacktrace Override' + ] + ); + + $span->events[] = $spanEvent; + + /** @var Span $otelSpan */ + $otelSpan = Span::getCurrent(); + $otelSpanEvent = $otelSpan->toSpanData()->getEvents()[0]; + + $this->assertSame('exception', $otelSpanEvent->getName()); + $this->assertSame([ + 'exception.message' => 'Test exception message', + 'exception.type' => 'Exception', + 'exception.stacktrace' => 'Stacktrace Override', + 'arg1' => 'value1' + ], $otelSpanEvent->getAttributes()->toArray()); + + close_span(); + }); + $event = json_decode($traces[0][0]['meta']['events'], true)[0]; + + $this->assertSame('Test exception message', $event['attributes']['exception.message']); + $this->assertSame('Exception', $event['attributes']['exception.type']); + $this->assertSame('Stacktrace Override', $event['attributes']['exception.stacktrace']); + $this->assertSame('value1', $event['attributes']['arg1']); + } } diff --git a/tests/ext/active_span.phpt b/tests/ext/active_span.phpt index 72ac13c2f7..b80ca376b6 100644 --- a/tests/ext/active_span.phpt +++ b/tests/ext/active_span.phpt @@ -28,7 +28,7 @@ var_dump(DDTrace\active_span() == DDTrace\active_span()); Hello, Datadog. greet tracer. bool(true) -object(DDTrace\RootSpanData)#%d (20) { +object(DDTrace\RootSpanData)#%d (21) { ["name"]=> string(15) "active_span.php" ["resource"]=> @@ -61,6 +61,9 @@ object(DDTrace\RootSpanData)#%d (20) { ["links"]=> array(0) { } + ["events"]=> + array(0) { + } ["peerServiceSources"]=> array(0) { } diff --git a/tests/ext/request-replayer/dd_trace_exception_span_event.phpt b/tests/ext/request-replayer/dd_trace_exception_span_event.phpt new file mode 100644 index 0000000000..645dfef29e --- /dev/null +++ b/tests/ext/request-replayer/dd_trace_exception_span_event.phpt @@ -0,0 +1,54 @@ +--TEST-- +DDTrace\ExceptionSpanEvent serialization with overridden attributes +--SKIPIF-- + +--ENV-- +DD_AGENT_HOST=request-replayer +DD_TRACE_AGENT_PORT=80 +DD_TRACE_AGENT_FLUSH_INTERVAL=333 +DD_TRACE_AUTO_FLUSH_ENABLED=1 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +--FILE-- +name = 'ExceptionClass.exceptionMethod'; + $exception = new \Exception("initial exception"); + $spanEvent = new ExceptionSpanEvent($exception, [ + "exception.message" => "override message", + "custom.attribute" => "custom value" + ]); + $span->events[] = $spanEvent; +}); + +$rr = new RequestReplayer(); + +try { + $exceptionClass = new ExceptionClass(); + $exceptionClass->exceptionMethod(); +} catch (\Exception $e) { + echo 'Caught exception: ' . $e->getMessage() . PHP_EOL; +} + +$replay = $rr->waitForDataAndReplay(); +$root = json_decode($replay["body"], true); +$spans = $root["chunks"][0]["spans"] ?? $root[0]; +$span = $spans[0]; + +var_dump($span['meta']['events']); +?> +--EXPECTF-- +Caught exception: Exception in method +string(%d) "[{"name":"exception","time_unix_nano":%d,"attributes":{"exception.message":"override message","exception.type":"Exception","exception.stacktrace":"#0 %s(%d): ExceptionClass->{closure}()\n#1 %s(%d): ExceptionClass->exceptionMethod()\n#2 {main}","custom.attribute":"custom value"}}]" diff --git a/tests/ext/request-replayer/dd_trace_span_event.phpt b/tests/ext/request-replayer/dd_trace_span_event.phpt new file mode 100644 index 0000000000..989260673c --- /dev/null +++ b/tests/ext/request-replayer/dd_trace_span_event.phpt @@ -0,0 +1,47 @@ +--TEST-- +DDTrace\SpanEvent serialization with attributes +--SKIPIF-- + +--ENV-- +DD_AGENT_HOST=request-replayer +DD_TRACE_AGENT_PORT=80 +DD_TRACE_AGENT_FLUSH_INTERVAL=333 +DD_TRACE_AUTO_FLUSH_ENABLED=1 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +--FILE-- +name = 'TestClass.testMethod'; + $spanEvent = new SpanEvent("event-name", [ + 'arg1' => 'value1', + 'int_array' => [3, 4], + 'string_array' => ["5", "6"] + ], 1720037568765201300); + $span->events[] = $spanEvent; +}); + +$rr = new RequestReplayer(); +$test = new TestClass(); +$test->testMethod(); +$replay = $rr->waitForDataAndReplay(); +$root = json_decode($replay["body"], true); +$spans = $root["chunks"][0]["spans"] ?? $root[0]; +$span = $spans[0]; +var_dump($span['meta']['events']); +?> +--EXPECT-- +In testMethod +string(134) "[{"name":"event-name","time_unix_nano":1720037568765201300,"attributes":{"arg1":"value1","int_array":[3,4],"string_array":["5","6"]}}]" diff --git a/tests/ext/sandbox/span_clone.phpt b/tests/ext/sandbox/span_clone.phpt index ac5b3d2c85..34fe3f3604 100644 --- a/tests/ext/sandbox/span_clone.phpt +++ b/tests/ext/sandbox/span_clone.phpt @@ -24,7 +24,7 @@ var_dump(dd_trace_serialize_closed_spans()); ?> --EXPECTF-- -object(DDTrace\RootSpanData)#%d (20) { +object(DDTrace\RootSpanData)#%d (21) { ["name"]=> string(3) "foo" ["resource"]=> @@ -57,6 +57,9 @@ object(DDTrace\RootSpanData)#%d (20) { ["links"]=> array(0) { } + ["events"]=> + array(0) { + } ["peerServiceSources"]=> array(0) { } @@ -87,7 +90,7 @@ object(DDTrace\RootSpanData)#%d (20) { ["gitMetadata"]=> NULL } -object(DDTrace\RootSpanData)#%d (20) { +object(DDTrace\RootSpanData)#%d (21) { ["name"]=> string(5) "dummy" ["resource"]=> @@ -120,6 +123,9 @@ object(DDTrace\RootSpanData)#%d (20) { ["links"]=> array(0) { } + ["events"]=> + array(0) { + } ["peerServiceSources"]=> array(0) { } @@ -135,7 +141,7 @@ object(DDTrace\RootSpanData)#%d (20) { NULL } ["active"]=> - object(DDTrace\RootSpanData)#%d (20) { + object(DDTrace\RootSpanData)#%d (21) { ["name"]=> string(3) "foo" ["resource"]=> @@ -168,6 +174,9 @@ object(DDTrace\RootSpanData)#%d (20) { ["links"]=> array(0) { } + ["events"]=> + array(0) { + } ["peerServiceSources"]=> array(0) { }