Repository: open-telemetry/opentelemetry-python

Issue:
 Zipkin exporter must support Zipkin v2 Protobuf format
As per spec: https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/trace/sdk_exporters/zipkin.md#considerations-for-legacy-v1-format


Discussion:
@codeboten i'm taking a stab at this if you care to assign me.

Resolving patch:
diff --git a/exporter/opentelemetry-exporter-zipkin/src/opentelemetry/exporter/zipkin/__init__.py b/exporter/opentelemetry-exporter-zipkin/src/opentelemetry/exporter/zipkin/__init__.py
--- a/exporter/opentelemetry-exporter-zipkin/src/opentelemetry/exporter/zipkin/__init__.py
+++ b/exporter/opentelemetry-exporter-zipkin/src/opentelemetry/exporter/zipkin/__init__.py
@@ -26,6 +26,9 @@
 .. _OpenTelemetry: https://github.com/open-telemetry/opentelemetry-python/
 .. _Specification: https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/sdk-environment-variables.md#zipkin-exporter
 
+.. envvar:: OTEL_EXPORTER_ZIPKIN_ENDPOINT
+.. envvar:: OTEL_EXPORTER_ZIPKIN_TRANSPORT_FORMAT
+
 .. code:: python
 
     from opentelemetry import trace
@@ -55,7 +58,14 @@
     with tracer.start_as_current_span("foo"):
         print("Hello world!")
 
-The exporter supports endpoint configuration via the OTEL_EXPORTER_ZIPKIN_ENDPOINT environment variables as defined in the `Specification`_
+The exporter supports the following environment variables for configuration:
+
+:envvar:`OTEL_EXPORTER_ZIPKIN_ENDPOINT`: target to which the exporter will
+send data. This may include a path (e.g. http://example.com:9411/api/v2/spans).
+
+:envvar:`OTEL_EXPORTER_ZIPKIN_TRANSPORT_FORMAT`: transport interchange format
+to use when sending data. Currently only Zipkin's v2 json and protobuf formats
+are supported, with v2 json being the default.
 
 API
 ---
@@ -63,21 +73,24 @@
 
 import json
 import logging
-import os
-from typing import Optional, Sequence
+from typing import Optional, Sequence, Union
 from urllib.parse import urlparse
 
 import requests
 
+from opentelemetry.configuration import Configuration
+from opentelemetry.exporter.zipkin.gen import zipkin_pb2
 from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
 from opentelemetry.trace import Span, SpanContext, SpanKind
 
+TRANSPORT_FORMAT_JSON = "json"
+TRANSPORT_FORMAT_PROTOBUF = "protobuf"
+
 DEFAULT_RETRY = False
 DEFAULT_URL = "http://localhost:9411/api/v2/spans"
 DEFAULT_MAX_TAG_VALUE_LENGTH = 128
-ZIPKIN_HEADERS = {"Content-Type": "application/json"}
 
-SPAN_KIND_MAP = {
+SPAN_KIND_MAP_JSON = {
     SpanKind.INTERNAL: None,
     SpanKind.SERVER: "SERVER",
     SpanKind.CLIENT: "CLIENT",
@@ -85,6 +98,14 @@
     SpanKind.CONSUMER: "CONSUMER",
 }
 
+SPAN_KIND_MAP_PROTOBUF = {
+    SpanKind.INTERNAL: zipkin_pb2.Span.Kind.SPAN_KIND_UNSPECIFIED,
+    SpanKind.SERVER: zipkin_pb2.Span.Kind.SERVER,
+    SpanKind.CLIENT: zipkin_pb2.Span.Kind.CLIENT,
+    SpanKind.PRODUCER: zipkin_pb2.Span.Kind.PRODUCER,
+    SpanKind.CONSUMER: zipkin_pb2.Span.Kind.CONSUMER,
+}
+
 SUCCESS_STATUS_CODES = (200, 202)
 
 logger = logging.getLogger(__name__)
@@ -100,6 +121,7 @@ class ZipkinSpanExporter(SpanExporter):
         ipv4: Primary IPv4 address associated with this connection.
         ipv6: Primary IPv6 address associated with this connection.
         retry: Set to True to configure the exporter to retry on failure.
+        transport_format: transport interchange format to use
     """
 
     def __init__(
@@ -110,12 +132,13 @@ def __init__(
         ipv6: Optional[str] = None,
         retry: Optional[str] = DEFAULT_RETRY,
         max_tag_value_length: Optional[int] = DEFAULT_MAX_TAG_VALUE_LENGTH,
+        transport_format: Union[
+            TRANSPORT_FORMAT_JSON, TRANSPORT_FORMAT_PROTOBUF, None
+        ] = None,
     ):
         self.service_name = service_name
         if url is None:
-            self.url = os.environ.get(
-                "OTEL_EXPORTER_ZIPKIN_ENDPOINT", DEFAULT_URL
-            )
+            self.url = Configuration().EXPORTER_ZIPKIN_ENDPOINT or DEFAULT_URL
         else:
             self.url = url
 
@@ -126,10 +149,27 @@ def __init__(
         self.retry = retry
         self.max_tag_value_length = max_tag_value_length
 
+        if transport_format is None:
+            self.transport_format = (
+                Configuration().EXPORTER_ZIPKIN_TRANSPORT_FORMAT
+                or TRANSPORT_FORMAT_JSON
+            )
+        else:
+            self.transport_format = transport_format
+
     def export(self, spans: Sequence[Span]) -> SpanExportResult:
-        zipkin_spans = self._translate_to_zipkin(spans)
+        if self.transport_format == TRANSPORT_FORMAT_JSON:
+            content_type = "application/json"
+        elif self.transport_format == TRANSPORT_FORMAT_PROTOBUF:
+            content_type = "application/x-protobuf"
+        else:
+            logger.error("Invalid transport format %s", self.transport_format)
+            return SpanExportResult.FAILURE
+
         result = requests.post(
-            url=self.url, data=json.dumps(zipkin_spans), headers=ZIPKIN_HEADERS
+            url=self.url,
+            data=self._translate_to_transport_format(spans),
+            headers={"Content-Type": content_type},
         )
 
         if result.status_code not in SUCCESS_STATUS_CODES:
@@ -147,8 +187,14 @@ def export(self, spans: Sequence[Span]) -> SpanExportResult:
     def shutdown(self) -> None:
         pass
 
-    def _translate_to_zipkin(self, spans: Sequence[Span]):
+    def _translate_to_transport_format(self, spans: Sequence[Span]):
+        return (
+            self._translate_to_json(spans)
+            if self.transport_format == TRANSPORT_FORMAT_JSON
+            else self._translate_to_protobuf(spans)
+        )
 
+    def _translate_to_json(self, spans: Sequence[Span]):
         local_endpoint = {"serviceName": self.service_name, "port": self.port}
 
         if self.ipv4 is not None:
@@ -165,8 +211,8 @@ def _translate_to_zipkin(self, spans: Sequence[Span]):
 
             # Timestamp in zipkin spans is int of microseconds.
             # see: https://zipkin.io/pages/instrumenting.html
-            start_timestamp_mus = _nsec_to_usec_round(span.start_time)
-            duration_mus = _nsec_to_usec_round(span.end_time - span.start_time)
+            start_timestamp_mus = nsec_to_usec_round(span.start_time)
+            duration_mus = nsec_to_usec_round(span.end_time - span.start_time)
 
             zipkin_span = {
                 # Ensure left-zero-padding of traceId, spanId, parentId
@@ -176,7 +222,7 @@ def _translate_to_zipkin(self, spans: Sequence[Span]):
                 "timestamp": start_timestamp_mus,
                 "duration": duration_mus,
                 "localEndpoint": local_endpoint,
-                "kind": SPAN_KIND_MAP[span.kind],
+                "kind": SPAN_KIND_MAP_JSON[span.kind],
                 "tags": self._extract_tags_from_span(span),
                 "annotations": self._extract_annotations_from_events(
                     span.events
@@ -211,7 +257,94 @@ def _translate_to_zipkin(self, spans: Sequence[Span]):
                 zipkin_span["parentId"] = format(span.parent.span_id, "016x")
 
             zipkin_spans.append(zipkin_span)
-        return zipkin_spans
+
+        return json.dumps(zipkin_spans)
+
+    def _translate_to_protobuf(self, spans: Sequence[Span]):
+
+        local_endpoint = zipkin_pb2.Endpoint(
+            service_name=self.service_name, port=self.port
+        )
+
+        if self.ipv4 is not None:
+            local_endpoint.ipv4 = self.ipv4
+
+        if self.ipv6 is not None:
+            local_endpoint.ipv6 = self.ipv6
+
+        pbuf_spans = zipkin_pb2.ListOfSpans()
+
+        for span in spans:
+            context = span.get_span_context()
+            trace_id = context.trace_id.to_bytes(
+                length=16, byteorder="big", signed=False,
+            )
+            span_id = self.format_pbuf_span_id(context.span_id)
+
+            # Timestamp in zipkin spans is int of microseconds.
+            # see: https://zipkin.io/pages/instrumenting.html
+            start_timestamp_mus = nsec_to_usec_round(span.start_time)
+            duration_mus = nsec_to_usec_round(span.end_time - span.start_time)
+
+            # pylint: disable=no-member
+            pbuf_span = zipkin_pb2.Span(
+                trace_id=trace_id,
+                id=span_id,
+                name=span.name,
+                timestamp=start_timestamp_mus,
+                duration=duration_mus,
+                local_endpoint=local_endpoint,
+                kind=SPAN_KIND_MAP_PROTOBUF[span.kind],
+                tags=self._extract_tags_from_span(span),
+            )
+
+            annotations = self._extract_annotations_from_events(span.events)
+
+            if annotations is not None:
+                for annotation in annotations:
+                    pbuf_span.annotations.append(
+                        zipkin_pb2.Annotation(
+                            timestamp=annotation["timestamp"],
+                            value=annotation["value"],
+                        )
+                    )
+
+            if span.instrumentation_info is not None:
+                pbuf_span.tags.update(
+                    {
+                        "otel.instrumentation_library.name": span.instrumentation_info.name,
+                        "otel.instrumentation_library.version": span.instrumentation_info.version,
+                    }
+                )
+
+            if span.status is not None:
+                pbuf_span.tags.update(
+                    {"otel.status_code": str(span.status.status_code.value)}
+                )
+                if span.status.description is not None:
+                    pbuf_span.tags.update(
+                        {"otel.status_description": span.status.description}
+                    )
+
+            if context.trace_flags.sampled:
+                pbuf_span.debug = True
+
+            if isinstance(span.parent, Span):
+                pbuf_span.parent_id = self.format_pbuf_span_id(
+                    span.parent.get_span_context().span_id
+                )
+            elif isinstance(span.parent, SpanContext):
+                pbuf_span.parent_id = self.format_pbuf_span_id(
+                    span.parent.span_id
+                )
+
+            pbuf_spans.spans.append(pbuf_span)
+
+        return pbuf_spans.SerializeToString()
+
+    @staticmethod
+    def format_pbuf_span_id(span_id: int):
+        return span_id.to_bytes(length=8, byteorder="big", signed=False)
 
     def _extract_tags_from_dict(self, tags_dict):
         tags = {}
@@ -251,13 +384,13 @@ def _extract_annotations_from_events(self, events):
 
             annotations.append(
                 {
-                    "timestamp": _nsec_to_usec_round(event.timestamp),
+                    "timestamp": nsec_to_usec_round(event.timestamp),
                     "value": json.dumps({event.name: attrs}),
                 }
             )
         return annotations
 
 
-def _nsec_to_usec_round(nsec):
+def nsec_to_usec_round(nsec):
     """Round nanoseconds to microseconds"""
     return (nsec + 500) // 10 ** 3
diff --git a/exporter/opentelemetry-exporter-zipkin/src/opentelemetry/exporter/zipkin/gen/__init__.py b/exporter/opentelemetry-exporter-zipkin/src/opentelemetry/exporter/zipkin/gen/__init__.py
new file mode 100644
diff --git a/exporter/opentelemetry-exporter-zipkin/src/opentelemetry/exporter/zipkin/gen/zipkin_pb2.py b/exporter/opentelemetry-exporter-zipkin/src/opentelemetry/exporter/zipkin/gen/zipkin_pb2.py
new file mode 100644
--- /dev/null
+++ b/exporter/opentelemetry-exporter-zipkin/src/opentelemetry/exporter/zipkin/gen/zipkin_pb2.py
@@ -0,0 +1,458 @@
+# -*- coding: utf-8 -*-
+# Generated by the protocol buffer compiler.  DO NOT EDIT!
+# source: zipkin.proto
+"""Generated protocol buffer code."""
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import message as _message
+from google.protobuf import reflection as _reflection
+from google.protobuf import symbol_database as _symbol_database
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+
+
+DESCRIPTOR = _descriptor.FileDescriptor(
+  name='zipkin.proto',
+  package='zipkin.proto3',
+  syntax='proto3',
+  serialized_options=b'\n\016zipkin2.proto3P\001',
+  create_key=_descriptor._internal_create_key,
+  serialized_pb=b'\n\x0czipkin.proto\x12\rzipkin.proto3\"\xf5\x03\n\x04Span\x12\x10\n\x08trace_id\x18\x01 \x01(\x0c\x12\x11\n\tparent_id\x18\x02 \x01(\x0c\x12\n\n\x02id\x18\x03 \x01(\x0c\x12&\n\x04kind\x18\x04 \x01(\x0e\x32\x18.zipkin.proto3.Span.Kind\x12\x0c\n\x04name\x18\x05 \x01(\t\x12\x11\n\ttimestamp\x18\x06 \x01(\x06\x12\x10\n\x08\x64uration\x18\x07 \x01(\x04\x12/\n\x0elocal_endpoint\x18\x08 \x01(\x0b\x32\x17.zipkin.proto3.Endpoint\x12\x30\n\x0fremote_endpoint\x18\t \x01(\x0b\x32\x17.zipkin.proto3.Endpoint\x12.\n\x0b\x61nnotations\x18\n \x03(\x0b\x32\x19.zipkin.proto3.Annotation\x12+\n\x04tags\x18\x0b \x03(\x0b\x32\x1d.zipkin.proto3.Span.TagsEntry\x12\r\n\x05\x64\x65\x62ug\x18\x0c \x01(\x08\x12\x0e\n\x06shared\x18\r \x01(\x08\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"U\n\x04Kind\x12\x19\n\x15SPAN_KIND_UNSPECIFIED\x10\x00\x12\n\n\x06\x43LIENT\x10\x01\x12\n\n\x06SERVER\x10\x02\x12\x0c\n\x08PRODUCER\x10\x03\x12\x0c\n\x08\x43ONSUMER\x10\x04\"J\n\x08\x45ndpoint\x12\x14\n\x0cservice_name\x18\x01 \x01(\t\x12\x0c\n\x04ipv4\x18\x02 \x01(\x0c\x12\x0c\n\x04ipv6\x18\x03 \x01(\x0c\x12\x0c\n\x04port\x18\x04 \x01(\x05\".\n\nAnnotation\x12\x11\n\ttimestamp\x18\x01 \x01(\x06\x12\r\n\x05value\x18\x02 \x01(\t\"1\n\x0bListOfSpans\x12\"\n\x05spans\x18\x01 \x03(\x0b\x32\x13.zipkin.proto3.Span\"\x10\n\x0eReportResponse2T\n\x0bSpanService\x12\x45\n\x06Report\x12\x1a.zipkin.proto3.ListOfSpans\x1a\x1d.zipkin.proto3.ReportResponse\"\x00\x42\x12\n\x0ezipkin2.proto3P\x01\x62\x06proto3'
+)
+
+
+
+_SPAN_KIND = _descriptor.EnumDescriptor(
+  name='Kind',
+  full_name='zipkin.proto3.Span.Kind',
+  filename=None,
+  file=DESCRIPTOR,
+  create_key=_descriptor._internal_create_key,
+  values=[
+    _descriptor.EnumValueDescriptor(
+      name='SPAN_KIND_UNSPECIFIED', index=0, number=0,
+      serialized_options=None,
+      type=None,
+      create_key=_descriptor._internal_create_key),
+    _descriptor.EnumValueDescriptor(
+      name='CLIENT', index=1, number=1,
+      serialized_options=None,
+      type=None,
+      create_key=_descriptor._internal_create_key),
+    _descriptor.EnumValueDescriptor(
+      name='SERVER', index=2, number=2,
+      serialized_options=None,
+      type=None,
+      create_key=_descriptor._internal_create_key),
+    _descriptor.EnumValueDescriptor(
+      name='PRODUCER', index=3, number=3,
+      serialized_options=None,
+      type=None,
+      create_key=_descriptor._internal_create_key),
+    _descriptor.EnumValueDescriptor(
+      name='CONSUMER', index=4, number=4,
+      serialized_options=None,
+      type=None,
+      create_key=_descriptor._internal_create_key),
+  ],
+  containing_type=None,
+  serialized_options=None,
+  serialized_start=448,
+  serialized_end=533,
+)
+_sym_db.RegisterEnumDescriptor(_SPAN_KIND)
+
+
+_SPAN_TAGSENTRY = _descriptor.Descriptor(
+  name='TagsEntry',
+  full_name='zipkin.proto3.Span.TagsEntry',
+  filename=None,
+  file=DESCRIPTOR,
+  containing_type=None,
+  create_key=_descriptor._internal_create_key,
+  fields=[
+    _descriptor.FieldDescriptor(
+      name='key', full_name='zipkin.proto3.Span.TagsEntry.key', index=0,
+      number=1, type=9, cpp_type=9, label=1,
+      has_default_value=False, default_value=b"".decode('utf-8'),
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+    _descriptor.FieldDescriptor(
+      name='value', full_name='zipkin.proto3.Span.TagsEntry.value', index=1,
+      number=2, type=9, cpp_type=9, label=1,
+      has_default_value=False, default_value=b"".decode('utf-8'),
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+  ],
+  extensions=[
+  ],
+  nested_types=[],
+  enum_types=[
+  ],
+  serialized_options=b'8\001',
+  is_extendable=False,
+  syntax='proto3',
+  extension_ranges=[],
+  oneofs=[
+  ],
+  serialized_start=403,
+  serialized_end=446,
+)
+
+_SPAN = _descriptor.Descriptor(
+  name='Span',
+  full_name='zipkin.proto3.Span',
+  filename=None,
+  file=DESCRIPTOR,
+  containing_type=None,
+  create_key=_descriptor._internal_create_key,
+  fields=[
+    _descriptor.FieldDescriptor(
+      name='trace_id', full_name='zipkin.proto3.Span.trace_id', index=0,
+      number=1, type=12, cpp_type=9, label=1,
+      has_default_value=False, default_value=b"",
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+    _descriptor.FieldDescriptor(
+      name='parent_id', full_name='zipkin.proto3.Span.parent_id', index=1,
+      number=2, type=12, cpp_type=9, label=1,
+      has_default_value=False, default_value=b"",
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+    _descriptor.FieldDescriptor(
+      name='id', full_name='zipkin.proto3.Span.id', index=2,
+      number=3, type=12, cpp_type=9, label=1,
+      has_default_value=False, default_value=b"",
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+    _descriptor.FieldDescriptor(
+      name='kind', full_name='zipkin.proto3.Span.kind', index=3,
+      number=4, type=14, cpp_type=8, label=1,
+      has_default_value=False, default_value=0,
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+    _descriptor.FieldDescriptor(
+      name='name', full_name='zipkin.proto3.Span.name', index=4,
+      number=5, type=9, cpp_type=9, label=1,
+      has_default_value=False, default_value=b"".decode('utf-8'),
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+    _descriptor.FieldDescriptor(
+      name='timestamp', full_name='zipkin.proto3.Span.timestamp', index=5,
+      number=6, type=6, cpp_type=4, label=1,
+      has_default_value=False, default_value=0,
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+    _descriptor.FieldDescriptor(
+      name='duration', full_name='zipkin.proto3.Span.duration', index=6,
+      number=7, type=4, cpp_type=4, label=1,
+      has_default_value=False, default_value=0,
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+    _descriptor.FieldDescriptor(
+      name='local_endpoint', full_name='zipkin.proto3.Span.local_endpoint', index=7,
+      number=8, type=11, cpp_type=10, label=1,
+      has_default_value=False, default_value=None,
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+    _descriptor.FieldDescriptor(
+      name='remote_endpoint', full_name='zipkin.proto3.Span.remote_endpoint', index=8,
+      number=9, type=11, cpp_type=10, label=1,
+      has_default_value=False, default_value=None,
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+    _descriptor.FieldDescriptor(
+      name='annotations', full_name='zipkin.proto3.Span.annotations', index=9,
+      number=10, type=11, cpp_type=10, label=3,
+      has_default_value=False, default_value=[],
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+    _descriptor.FieldDescriptor(
+      name='tags', full_name='zipkin.proto3.Span.tags', index=10,
+      number=11, type=11, cpp_type=10, label=3,
+      has_default_value=False, default_value=[],
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+    _descriptor.FieldDescriptor(
+      name='debug', full_name='zipkin.proto3.Span.debug', index=11,
+      number=12, type=8, cpp_type=7, label=1,
+      has_default_value=False, default_value=False,
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+    _descriptor.FieldDescriptor(
+      name='shared', full_name='zipkin.proto3.Span.shared', index=12,
+      number=13, type=8, cpp_type=7, label=1,
+      has_default_value=False, default_value=False,
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+  ],
+  extensions=[
+  ],
+  nested_types=[_SPAN_TAGSENTRY, ],
+  enum_types=[
+    _SPAN_KIND,
+  ],
+  serialized_options=None,
+  is_extendable=False,
+  syntax='proto3',
+  extension_ranges=[],
+  oneofs=[
+  ],
+  serialized_start=32,
+  serialized_end=533,
+)
+
+
+_ENDPOINT = _descriptor.Descriptor(
+  name='Endpoint',
+  full_name='zipkin.proto3.Endpoint',
+  filename=None,
+  file=DESCRIPTOR,
+  containing_type=None,
+  create_key=_descriptor._internal_create_key,
+  fields=[
+    _descriptor.FieldDescriptor(
+      name='service_name', full_name='zipkin.proto3.Endpoint.service_name', index=0,
+      number=1, type=9, cpp_type=9, label=1,
+      has_default_value=False, default_value=b"".decode('utf-8'),
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+    _descriptor.FieldDescriptor(
+      name='ipv4', full_name='zipkin.proto3.Endpoint.ipv4', index=1,
+      number=2, type=12, cpp_type=9, label=1,
+      has_default_value=False, default_value=b"",
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+    _descriptor.FieldDescriptor(
+      name='ipv6', full_name='zipkin.proto3.Endpoint.ipv6', index=2,
+      number=3, type=12, cpp_type=9, label=1,
+      has_default_value=False, default_value=b"",
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+    _descriptor.FieldDescriptor(
+      name='port', full_name='zipkin.proto3.Endpoint.port', index=3,
+      number=4, type=5, cpp_type=1, label=1,
+      has_default_value=False, default_value=0,
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+  ],
+  extensions=[
+  ],
+  nested_types=[],
+  enum_types=[
+  ],
+  serialized_options=None,
+  is_extendable=False,
+  syntax='proto3',
+  extension_ranges=[],
+  oneofs=[
+  ],
+  serialized_start=535,
+  serialized_end=609,
+)
+
+
+_ANNOTATION = _descriptor.Descriptor(
+  name='Annotation',
+  full_name='zipkin.proto3.Annotation',
+  filename=None,
+  file=DESCRIPTOR,
+  containing_type=None,
+  create_key=_descriptor._internal_create_key,
+  fields=[
+    _descriptor.FieldDescriptor(
+      name='timestamp', full_name='zipkin.proto3.Annotation.timestamp', index=0,
+      number=1, type=6, cpp_type=4, label=1,
+      has_default_value=False, default_value=0,
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+    _descriptor.FieldDescriptor(
+      name='value', full_name='zipkin.proto3.Annotation.value', index=1,
+      number=2, type=9, cpp_type=9, label=1,
+      has_default_value=False, default_value=b"".decode('utf-8'),
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+  ],
+  extensions=[
+  ],
+  nested_types=[],
+  enum_types=[
+  ],
+  serialized_options=None,
+  is_extendable=False,
+  syntax='proto3',
+  extension_ranges=[],
+  oneofs=[
+  ],
+  serialized_start=611,
+  serialized_end=657,
+)
+
+
+_LISTOFSPANS = _descriptor.Descriptor(
+  name='ListOfSpans',
+  full_name='zipkin.proto3.ListOfSpans',
+  filename=None,
+  file=DESCRIPTOR,
+  containing_type=None,
+  create_key=_descriptor._internal_create_key,
+  fields=[
+    _descriptor.FieldDescriptor(
+      name='spans', full_name='zipkin.proto3.ListOfSpans.spans', index=0,
+      number=1, type=11, cpp_type=10, label=3,
+      has_default_value=False, default_value=[],
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
+  ],
+  extensions=[
+  ],
+  nested_types=[],
+  enum_types=[
+  ],
+  serialized_options=None,
+  is_extendable=False,
+  syntax='proto3',
+  extension_ranges=[],
+  oneofs=[
+  ],
+  serialized_start=659,
+  serialized_end=708,
+)
+
+
+_REPORTRESPONSE = _descriptor.Descriptor(
+  name='ReportResponse',
+  full_name='zipkin.proto3.ReportResponse',
+  filename=None,
+  file=DESCRIPTOR,
+  containing_type=None,
+  create_key=_descriptor._internal_create_key,
+  fields=[
+  ],
+  extensions=[
+  ],
+  nested_types=[],
+  enum_types=[
+  ],
+  serialized_options=None,
+  is_extendable=False,
+  syntax='proto3',
+  extension_ranges=[],
+  oneofs=[
+  ],
+  serialized_start=710,
+  serialized_end=726,
+)
+
+_SPAN_TAGSENTRY.containing_type = _SPAN
+_SPAN.fields_by_name['kind'].enum_type = _SPAN_KIND
+_SPAN.fields_by_name['local_endpoint'].message_type = _ENDPOINT
+_SPAN.fields_by_name['remote_endpoint'].message_type = _ENDPOINT
+_SPAN.fields_by_name['annotations'].message_type = _ANNOTATION
+_SPAN.fields_by_name['tags'].message_type = _SPAN_TAGSENTRY
+_SPAN_KIND.containing_type = _SPAN
+_LISTOFSPANS.fields_by_name['spans'].message_type = _SPAN
+DESCRIPTOR.message_types_by_name['Span'] = _SPAN
+DESCRIPTOR.message_types_by_name['Endpoint'] = _ENDPOINT
+DESCRIPTOR.message_types_by_name['Annotation'] = _ANNOTATION
+DESCRIPTOR.message_types_by_name['ListOfSpans'] = _LISTOFSPANS
+DESCRIPTOR.message_types_by_name['ReportResponse'] = _REPORTRESPONSE
+_sym_db.RegisterFileDescriptor(DESCRIPTOR)
+
+Span = _reflection.GeneratedProtocolMessageType('Span', (_message.Message,), {
+
+  'TagsEntry' : _reflection.GeneratedProtocolMessageType('TagsEntry', (_message.Message,), {
+    'DESCRIPTOR' : _SPAN_TAGSENTRY,
+    '__module__' : 'zipkin_pb2'
+    # @@protoc_insertion_point(class_scope:zipkin.proto3.Span.TagsEntry)
+    })
+  ,
+  'DESCRIPTOR' : _SPAN,
+  '__module__' : 'zipkin_pb2'
+  # @@protoc_insertion_point(class_scope:zipkin.proto3.Span)
+  })
+_sym_db.RegisterMessage(Span)
+_sym_db.RegisterMessage(Span.TagsEntry)
+
+Endpoint = _reflection.GeneratedProtocolMessageType('Endpoint', (_message.Message,), {
+  'DESCRIPTOR' : _ENDPOINT,
+  '__module__' : 'zipkin_pb2'
+  # @@protoc_insertion_point(class_scope:zipkin.proto3.Endpoint)
+  })
+_sym_db.RegisterMessage(Endpoint)
+
+Annotation = _reflection.GeneratedProtocolMessageType('Annotation', (_message.Message,), {
+  'DESCRIPTOR' : _ANNOTATION,
+  '__module__' : 'zipkin_pb2'
+  # @@protoc_insertion_point(class_scope:zipkin.proto3.Annotation)
+  })
+_sym_db.RegisterMessage(Annotation)
+
+ListOfSpans = _reflection.GeneratedProtocolMessageType('ListOfSpans', (_message.Message,), {
+  'DESCRIPTOR' : _LISTOFSPANS,
+  '__module__' : 'zipkin_pb2'
+  # @@protoc_insertion_point(class_scope:zipkin.proto3.ListOfSpans)
+  })
+_sym_db.RegisterMessage(ListOfSpans)
+
+ReportResponse = _reflection.GeneratedProtocolMessageType('ReportResponse', (_message.Message,), {
+  'DESCRIPTOR' : _REPORTRESPONSE,
+  '__module__' : 'zipkin_pb2'
+  # @@protoc_insertion_point(class_scope:zipkin.proto3.ReportResponse)
+  })
+_sym_db.RegisterMessage(ReportResponse)
+
+
+DESCRIPTOR._options = None
+_SPAN_TAGSENTRY._options = None
+
+_SPANSERVICE = _descriptor.ServiceDescriptor(
+  name='SpanService',
+  full_name='zipkin.proto3.SpanService',
+  file=DESCRIPTOR,
+  index=0,
+  serialized_options=None,
+  create_key=_descriptor._internal_create_key,
+  serialized_start=728,
+  serialized_end=812,
+  methods=[
+  _descriptor.MethodDescriptor(
+    name='Report',
+    full_name='zipkin.proto3.SpanService.Report',
+    index=0,
+    containing_service=None,
+    input_type=_LISTOFSPANS,
+    output_type=_REPORTRESPONSE,
+    serialized_options=None,
+    create_key=_descriptor._internal_create_key,
+  ),
+])
+_sym_db.RegisterServiceDescriptor(_SPANSERVICE)
+
+DESCRIPTOR.services_by_name['SpanService'] = _SPANSERVICE
+
+# @@protoc_insertion_point(module_scope)


Test patch:
diff --git a/exporter/opentelemetry-exporter-zipkin/tests/test_zipkin_exporter.py b/exporter/opentelemetry-exporter-zipkin/tests/test_zipkin_exporter.py
--- a/exporter/opentelemetry-exporter-zipkin/tests/test_zipkin_exporter.py
+++ b/exporter/opentelemetry-exporter-zipkin/tests/test_zipkin_exporter.py
@@ -18,12 +18,21 @@
 from unittest.mock import MagicMock, patch
 
 from opentelemetry import trace as trace_api
-from opentelemetry.exporter.zipkin import ZipkinSpanExporter
+from opentelemetry.configuration import Configuration
+from opentelemetry.exporter.zipkin import (
+    SPAN_KIND_MAP_JSON,
+    SPAN_KIND_MAP_PROTOBUF,
+    TRANSPORT_FORMAT_JSON,
+    TRANSPORT_FORMAT_PROTOBUF,
+    ZipkinSpanExporter,
+    nsec_to_usec_round,
+)
+from opentelemetry.exporter.zipkin.gen import zipkin_pb2
 from opentelemetry.sdk import trace
 from opentelemetry.sdk.trace import Resource
 from opentelemetry.sdk.trace.export import SpanExportResult
 from opentelemetry.sdk.util.instrumentation import InstrumentationInfo
-from opentelemetry.trace import TraceFlags
+from opentelemetry.trace import SpanKind, TraceFlags
 from opentelemetry.trace.status import Status, StatusCode
 
 
@@ -49,11 +58,17 @@ def setUp(self):
     def tearDown(self):
         if "OTEL_EXPORTER_ZIPKIN_ENDPOINT" in os.environ:
             del os.environ["OTEL_EXPORTER_ZIPKIN_ENDPOINT"]
+        if "OTEL_EXPORTER_ZIPKIN_TRANSPORT_FORMAT" in os.environ:
+            del os.environ["OTEL_EXPORTER_ZIPKIN_TRANSPORT_FORMAT"]
+        Configuration()._reset()  # pylint: disable=protected-access
 
     def test_constructor_env_var(self):
         """Test the default values assigned by constructor."""
         url = "https://foo:9911/path"
         os.environ["OTEL_EXPORTER_ZIPKIN_ENDPOINT"] = url
+        os.environ[
+            "OTEL_EXPORTER_ZIPKIN_TRANSPORT_FORMAT"
+        ] = TRANSPORT_FORMAT_PROTOBUF
         service_name = "my-service-name"
         port = 9911
         exporter = ZipkinSpanExporter(service_name)
@@ -65,6 +80,7 @@ def test_constructor_env_var(self):
         self.assertEqual(exporter.ipv6, ipv6)
         self.assertEqual(exporter.url, url)
         self.assertEqual(exporter.port, port)
+        self.assertEqual(exporter.transport_format, TRANSPORT_FORMAT_PROTOBUF)
 
     def test_constructor_default(self):
         """Test the default values assigned by constructor."""
@@ -74,12 +90,14 @@ def test_constructor_default(self):
         ipv4 = None
         ipv6 = None
         url = "http://localhost:9411/api/v2/spans"
+        transport_format = TRANSPORT_FORMAT_JSON
 
         self.assertEqual(exporter.service_name, service_name)
         self.assertEqual(exporter.port, port)
         self.assertEqual(exporter.ipv4, ipv4)
         self.assertEqual(exporter.ipv6, ipv6)
         self.assertEqual(exporter.url, url)
+        self.assertEqual(exporter.transport_format, transport_format)
 
     def test_constructor_explicit(self):
         """Test the constructor passing all the options."""
@@ -88,8 +106,14 @@ def test_constructor_explicit(self):
         ipv4 = "1.2.3.4"
         ipv6 = "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
         url = "https://opentelemetry.io:15875/myapi/traces?format=zipkin"
+        transport_format = TRANSPORT_FORMAT_PROTOBUF
+
         exporter = ZipkinSpanExporter(
-            service_name=service_name, url=url, ipv4=ipv4, ipv6=ipv6,
+            service_name=service_name,
+            url=url,
+            ipv4=ipv4,
+            ipv6=ipv6,
+            transport_format=transport_format,
         )
 
         self.assertEqual(exporter.service_name, service_name)
@@ -97,9 +121,10 @@ def test_constructor_explicit(self):
         self.assertEqual(exporter.ipv4, ipv4)
         self.assertEqual(exporter.ipv6, ipv6)
         self.assertEqual(exporter.url, url)
+        self.assertEqual(exporter.transport_format, transport_format)
 
     # pylint: disable=too-many-locals,too-many-statements
-    def test_export(self):
+    def test_export_json(self):
         span_names = ("test1", "test2", "test3", "test4")
         trace_id = 0x6E0C63257DE34C926F9EFCD03927272E
         span_id = 0x34BF92DEEFC58C92
@@ -205,6 +230,7 @@ def test_export(self):
 
         service_name = "test-service"
         local_endpoint = {"serviceName": service_name, "port": 9411}
+        span_kind = SPAN_KIND_MAP_JSON[SpanKind.INTERNAL]
 
         exporter = ZipkinSpanExporter(service_name)
         expected_spans = [
@@ -215,7 +241,7 @@ def test_export(self):
                 "timestamp": start_times[0] // 10 ** 3,
                 "duration": durations[0] // 10 ** 3,
                 "localEndpoint": local_endpoint,
-                "kind": None,
+                "kind": span_kind,
                 "tags": {
                     "key_bool": "False",
                     "key_string": "hello_world",
@@ -245,7 +271,7 @@ def test_export(self):
                 "timestamp": start_times[1] // 10 ** 3,
                 "duration": durations[1] // 10 ** 3,
                 "localEndpoint": local_endpoint,
-                "kind": None,
+                "kind": span_kind,
                 "tags": {
                     "key_resource": "some_resource",
                     "otel.status_code": "1",
@@ -259,7 +285,7 @@ def test_export(self):
                 "timestamp": start_times[2] // 10 ** 3,
                 "duration": durations[2] // 10 ** 3,
                 "localEndpoint": local_endpoint,
-                "kind": None,
+                "kind": span_kind,
                 "tags": {
                     "key_string": "hello_world",
                     "key_resource": "some_resource",
@@ -274,7 +300,7 @@ def test_export(self):
                 "timestamp": start_times[3] // 10 ** 3,
                 "duration": durations[3] // 10 ** 3,
                 "localEndpoint": local_endpoint,
-                "kind": None,
+                "kind": span_kind,
                 "tags": {
                     "otel.instrumentation_library.name": "name",
                     "otel.instrumentation_library.version": "version",
@@ -294,6 +320,7 @@ def test_export(self):
         kwargs = mock_post.call_args[1]
 
         self.assertEqual(kwargs["url"], "http://localhost:9411/api/v2/spans")
+        self.assertEqual(kwargs["headers"]["Content-Type"], "application/json")
         actual_spans = sorted(
             json.loads(kwargs["data"]), key=lambda span: span["timestamp"]
         )
@@ -307,7 +334,7 @@ def test_export(self):
             self.assertEqual(expected_annotations, actual_annotations)
 
     # pylint: disable=too-many-locals
-    def test_zero_padding(self):
+    def test_export_json_zero_padding(self):
         """test that hex ids starting with 0
         are properly padded to 16 or 32 hex chars
         when exported
@@ -355,7 +382,7 @@ def test_zero_padding(self):
                 "timestamp": start_time // 10 ** 3,
                 "duration": duration // 10 ** 3,
                 "localEndpoint": local_endpoint,
-                "kind": None,
+                "kind": SPAN_KIND_MAP_JSON[SpanKind.INTERNAL],
                 "tags": {"otel.status_code": "1"},
                 "annotations": None,
                 "debug": True,
@@ -383,7 +410,7 @@ def test_invalid_response(self, mock_post):
         status = exporter.export(spans)
         self.assertEqual(SpanExportResult.FAILURE, status)
 
-    def test_max_tag_length(self):
+    def test_export_json_max_tag_length(self):
         service_name = "test-service"
 
         span_context = trace_api.SpanContext(
@@ -427,3 +454,278 @@ def test_max_tag_length(self):
         tags = json.loads(kwargs["data"])[0]["tags"]
         self.assertEqual(len(tags["k1"]), 2)
         self.assertEqual(len(tags["k2"]), 2)
+
+    # pylint: disable=too-many-locals,too-many-statements
+    def test_export_protobuf(self):
+        span_names = ("test1", "test2", "test3", "test4")
+        trace_id = 0x6E0C63257DE34C926F9EFCD03927272E
+        span_id = 0x34BF92DEEFC58C92
+        parent_id = 0x1111111111111111
+        other_id = 0x2222222222222222
+
+        base_time = 683647322 * 10 ** 9  # in ns
+        start_times = (
+            base_time,
+            base_time + 150 * 10 ** 6,
+            base_time + 300 * 10 ** 6,
+            base_time + 400 * 10 ** 6,
+        )
+        durations = (50 * 10 ** 6, 100 * 10 ** 6, 200 * 10 ** 6, 300 * 10 ** 6)
+        end_times = (
+            start_times[0] + durations[0],
+            start_times[1] + durations[1],
+            start_times[2] + durations[2],
+            start_times[3] + durations[3],
+        )
+
+        span_context = trace_api.SpanContext(
+            trace_id,
+            span_id,
+            is_remote=False,
+            trace_flags=TraceFlags(TraceFlags.SAMPLED),
+        )
+        parent_span_context = trace_api.SpanContext(
+            trace_id, parent_id, is_remote=False
+        )
+        other_context = trace_api.SpanContext(
+            trace_id, other_id, is_remote=False
+        )
+
+        event_attributes = {
+            "annotation_bool": True,
+            "annotation_string": "annotation_test",
+            "key_float": 0.3,
+        }
+
+        event_timestamp = base_time + 50 * 10 ** 6
+        event = trace.Event(
+            name="event0",
+            timestamp=event_timestamp,
+            attributes=event_attributes,
+        )
+
+        link_attributes = {"key_bool": True}
+
+        link = trace_api.Link(
+            context=other_context, attributes=link_attributes
+        )
+
+        otel_spans = [
+            trace._Span(
+                name=span_names[0],
+                context=span_context,
+                parent=parent_span_context,
+                events=(event,),
+                links=(link,),
+            ),
+            trace._Span(
+                name=span_names[1], context=parent_span_context, parent=None
+            ),
+            trace._Span(
+                name=span_names[2], context=other_context, parent=None
+            ),
+            trace._Span(
+                name=span_names[3], context=other_context, parent=None
+            ),
+        ]
+
+        otel_spans[0].start(start_time=start_times[0])
+        otel_spans[0].resource = Resource({})
+        # added here to preserve order
+        otel_spans[0].set_attribute("key_bool", False)
+        otel_spans[0].set_attribute("key_string", "hello_world")
+        otel_spans[0].set_attribute("key_float", 111.22)
+        otel_spans[0].set_status(
+            Status(StatusCode.ERROR, "Example description")
+        )
+        otel_spans[0].end(end_time=end_times[0])
+
+        otel_spans[1].start(start_time=start_times[1])
+        otel_spans[1].resource = Resource(
+            attributes={"key_resource": "some_resource"}
+        )
+        otel_spans[1].end(end_time=end_times[1])
+
+        otel_spans[2].start(start_time=start_times[2])
+        otel_spans[2].set_attribute("key_string", "hello_world")
+        otel_spans[2].resource = Resource(
+            attributes={"key_resource": "some_resource"}
+        )
+        otel_spans[2].end(end_time=end_times[2])
+
+        otel_spans[3].start(start_time=start_times[3])
+        otel_spans[3].resource = Resource({})
+        otel_spans[3].end(end_time=end_times[3])
+        otel_spans[3].instrumentation_info = InstrumentationInfo(
+            name="name", version="version"
+        )
+
+        service_name = "test-service"
+        local_endpoint = zipkin_pb2.Endpoint(
+            service_name=service_name, port=9411
+        )
+        span_kind = SPAN_KIND_MAP_PROTOBUF[SpanKind.INTERNAL]
+
+        expected_spans = zipkin_pb2.ListOfSpans(
+            spans=[
+                zipkin_pb2.Span(
+                    trace_id=trace_id.to_bytes(
+                        length=16, byteorder="big", signed=False
+                    ),
+                    id=ZipkinSpanExporter.format_pbuf_span_id(span_id),
+                    name=span_names[0],
+                    timestamp=nsec_to_usec_round(start_times[0]),
+                    duration=nsec_to_usec_round(durations[0]),
+                    local_endpoint=local_endpoint,
+                    kind=span_kind,
+                    tags={
+                        "key_bool": "False",
+                        "key_string": "hello_world",
+                        "key_float": "111.22",
+                        "otel.status_code": "2",
+                        "otel.status_description": "Example description",
+                    },
+                    debug=True,
+                    parent_id=ZipkinSpanExporter.format_pbuf_span_id(
+                        parent_id
+                    ),
+                    annotations=[
+                        zipkin_pb2.Annotation(
+                            timestamp=nsec_to_usec_round(event_timestamp),
+                            value=json.dumps(
+                                {
+                                    "event0": {
+                                        "annotation_bool": True,
+                                        "annotation_string": "annotation_test",
+                                        "key_float": 0.3,
+                                    }
+                                }
+                            ),
+                        ),
+                    ],
+                ),
+                zipkin_pb2.Span(
+                    trace_id=trace_id.to_bytes(
+                        length=16, byteorder="big", signed=False
+                    ),
+                    id=ZipkinSpanExporter.format_pbuf_span_id(parent_id),
+                    name=span_names[1],
+                    timestamp=nsec_to_usec_round(start_times[1]),
+                    duration=nsec_to_usec_round(durations[1]),
+                    local_endpoint=local_endpoint,
+                    kind=span_kind,
+                    tags={
+                        "key_resource": "some_resource",
+                        "otel.status_code": "1",
+                    },
+                ),
+                zipkin_pb2.Span(
+                    trace_id=trace_id.to_bytes(
+                        length=16, byteorder="big", signed=False
+                    ),
+                    id=ZipkinSpanExporter.format_pbuf_span_id(other_id),
+                    name=span_names[2],
+                    timestamp=nsec_to_usec_round(start_times[2]),
+                    duration=nsec_to_usec_round(durations[2]),
+                    local_endpoint=local_endpoint,
+                    kind=span_kind,
+                    tags={
+                        "key_string": "hello_world",
+                        "key_resource": "some_resource",
+                        "otel.status_code": "1",
+                    },
+                ),
+                zipkin_pb2.Span(
+                    trace_id=trace_id.to_bytes(
+                        length=16, byteorder="big", signed=False
+                    ),
+                    id=ZipkinSpanExporter.format_pbuf_span_id(other_id),
+                    name=span_names[3],
+                    timestamp=nsec_to_usec_round(start_times[3]),
+                    duration=nsec_to_usec_round(durations[3]),
+                    local_endpoint=local_endpoint,
+                    kind=span_kind,
+                    tags={
+                        "otel.instrumentation_library.name": "name",
+                        "otel.instrumentation_library.version": "version",
+                        "otel.status_code": "1",
+                    },
+                ),
+            ],
+        )
+
+        exporter = ZipkinSpanExporter(
+            service_name, transport_format=TRANSPORT_FORMAT_PROTOBUF
+        )
+        mock_post = MagicMock()
+        with patch("requests.post", mock_post):
+            mock_post.return_value = MockResponse(200)
+            status = exporter.export(otel_spans)
+            self.assertEqual(SpanExportResult.SUCCESS, status)
+
+        # pylint: disable=unsubscriptable-object
+        kwargs = mock_post.call_args[1]
+
+        self.assertEqual(kwargs["url"], "http://localhost:9411/api/v2/spans")
+        self.assertEqual(
+            kwargs["headers"]["Content-Type"], "application/x-protobuf"
+        )
+        self.assertEqual(
+            zipkin_pb2.ListOfSpans.FromString(kwargs["data"]), expected_spans
+        )
+
+    def test_export_protobuf_max_tag_length(self):
+        service_name = "test-service"
+
+        span_context = trace_api.SpanContext(
+            0x0E0C63257DE34C926F9EFCD03927272E,
+            0x04BF92DEEFC58C92,
+            is_remote=False,
+            trace_flags=TraceFlags(TraceFlags.SAMPLED),
+        )
+
+        span = trace._Span(name="test-span", context=span_context,)
+
+        span.start()
+        span.resource = Resource({})
+        # added here to preserve order
+        span.set_attribute("k1", "v" * 500)
+        span.set_attribute("k2", "v" * 50)
+        span.set_status(Status(StatusCode.ERROR, "Example description"))
+        span.end()
+
+        exporter = ZipkinSpanExporter(
+            service_name, transport_format=TRANSPORT_FORMAT_PROTOBUF,
+        )
+        mock_post = MagicMock()
+        with patch("requests.post", mock_post):
+            mock_post.return_value = MockResponse(200)
+            status = exporter.export([span])
+            self.assertEqual(SpanExportResult.SUCCESS, status)
+
+        # pylint: disable=unsubscriptable-object
+        kwargs = mock_post.call_args[1]
+        actual_spans = zipkin_pb2.ListOfSpans.FromString(kwargs["data"])
+        span_tags = actual_spans.spans[0].tags
+
+        self.assertEqual(len(span_tags["k1"]), 128)
+        self.assertEqual(len(span_tags["k2"]), 50)
+
+        exporter = ZipkinSpanExporter(
+            service_name,
+            transport_format=TRANSPORT_FORMAT_PROTOBUF,
+            max_tag_value_length=2,
+        )
+        mock_post = MagicMock()
+        with patch("requests.post", mock_post):
+            mock_post.return_value = MockResponse(200)
+            status = exporter.export([span])
+            self.assertEqual(SpanExportResult.SUCCESS, status)
+
+        # pylint: disable=unsubscriptable-object
+        kwargs = mock_post.call_args[1]
+        actual_spans = zipkin_pb2.ListOfSpans.FromString(kwargs["data"])
+        span_tags = actual_spans.spans[0].tags
+
+        self.assertEqual(len(span_tags["k1"]), 2)
+        self.assertEqual(len(span_tags["k2"]), 2)