diff --git a/log/zap/buffered_write_syncer.go b/log/zap/buffered_write_syncer.go
new file mode 100644
index 0000000000000000000000000000000000000000..6e2edc6f5d8588c4246b85f6722531b558df163c
--- /dev/null
+++ b/log/zap/buffered_write_syncer.go
@@ -0,0 +1,181 @@
+package zap
+
+import (
+	"context"
+	"sync"
+	"time"
+
+	"git.perx.ru/perxis/perxis-go/log"
+	"git.perx.ru/perxis/perxis-go/pkg/errors"
+)
+
+const (
+	defaultMaxBufferSize    = 1000
+	defaultMaxSyncQueueSize = 16
+	defaultFlushInterval    = 5 * time.Second
+)
+
+var SyncQueueOverflow = errors.New("sync queue overflow")
+
+// BufferedWriteSyncer это WriteSyncer, который отправляет записи в log.Service.
+// Когда количество буферизированных записей достигает некоторого предела или проходит определенный фиксированный интервал,
+// записи отправляются в очередь для синхронизации с log.Service.
+type BufferedWriteSyncer struct {
+	// FlushInterval устанавливает интервал, через который буферизированные записи будут отправлены на синхронизацию.
+	//
+	// Значение по умолчанию для этого параметра равно 5 секунд.
+	FlushInterval time.Duration
+
+	// MaxBufferSize устанавливает максимальное количество записей, которые могут быть буферизованы.
+	// Когда количество буферизованных записей превысит этот порог, они будут отправлены на синхронизацию в log.Service.
+	//
+	// Значение по умолчанию для этого параметра равно 1000.
+	MaxBufferSize int
+
+	// MaxSyncQueueSize устанавливает максимальный размер очереди записей на синхронизацию с log.Service.
+	//
+	// Значение по умолчанию для этого параметра равно 16.
+	MaxSyncQueueSize int
+
+	// Service сервис для хранения записей
+	Service log.Service
+
+	wg        sync.WaitGroup
+	mu        sync.RWMutex
+	buffer    []*log.Entry
+	syncQueue chan []*log.Entry
+
+	flushStop chan struct{} // flushStop закрывается, когда flushLoop должен быть остановлен
+	started   bool          // started указывает, был ли выполнен Start
+	stopped   bool          // stopped указывает, был ли выполнен Stop
+}
+
+func (ws *BufferedWriteSyncer) start() {
+	if ws.Service == nil {
+		panic("service is required")
+	}
+
+	if ws.FlushInterval == 0 {
+		ws.FlushInterval = defaultFlushInterval
+	}
+
+	if ws.MaxBufferSize == 0 {
+		ws.MaxBufferSize = defaultMaxBufferSize
+	}
+
+	if ws.MaxSyncQueueSize == 0 {
+		ws.MaxSyncQueueSize = defaultMaxSyncQueueSize
+	}
+
+	ws.buffer = make([]*log.Entry, 0, ws.MaxBufferSize)
+	ws.syncQueue = make(chan []*log.Entry, ws.MaxSyncQueueSize)
+	ws.flushStop = make(chan struct{})
+
+	ws.wg.Add(2)
+	go ws.syncLoop()
+	go ws.flushLoop()
+
+	ws.started = true
+}
+
+func (ws *BufferedWriteSyncer) Stop() error {
+	ws.mu.Lock()
+	defer ws.mu.Unlock()
+
+	if !ws.started || ws.stopped {
+		return nil
+	}
+	ws.stopped = true
+
+	close(ws.flushStop) // завершаем flushLoop
+
+	err := ws.flush() // очищаем оставшиеся записи
+
+	close(ws.syncQueue) // завершаем syncLoop
+
+	ws.wg.Wait() // дожидаемся завершения flushLoop и syncLoop
+
+	return err
+}
+
+// Write отправляет запись в буфер.
+// Когда количество буферизованных записей превышает максимальный размер буфера, буферизированные записи будут отправлены на синхронизацию.
+func (ws *BufferedWriteSyncer) Write(entry *log.Entry) error {
+	ws.mu.Lock()
+	defer ws.mu.Unlock()
+
+	if !ws.started {
+		ws.start()
+	}
+
+	// Проверяем, не достигли ли мы предела размера буфера. Если это так, тогда освобождаем его.
+	if len(ws.buffer)+1 > ws.MaxBufferSize {
+		err := ws.flush()
+		if err != nil {
+			return err
+		}
+	}
+
+	ws.buffer = append(ws.buffer, entry)
+
+	return nil
+}
+
+// Sync освобождает буфер и отправляет буферизированные записи на синхронизацию.
+func (ws *BufferedWriteSyncer) Sync() error {
+	ws.mu.Lock()
+	defer ws.mu.Unlock()
+
+	if ws.started {
+		return ws.flush()
+	}
+
+	return nil
+}
+
+// flush освобождает буфер и отправляет буферизированные записи на синхронизацию.
+// Если очередь на синхронизацию переполнена, будет возвращена ошибка SyncQueueOverflow
+//
+// ВНИМАНИЕ: Не является безопасным для конкурентного вызова.
+func (ws *BufferedWriteSyncer) flush() error {
+	if len(ws.buffer) == 0 {
+		return nil
+	}
+
+	// Проверяем, не достигли ли мы предела размера очереди. Если это так, возвращаем ошибку.
+	if len(ws.syncQueue)+1 > ws.MaxSyncQueueSize {
+		return SyncQueueOverflow
+	}
+
+	ws.syncQueue <- ws.buffer
+	ws.buffer = make([]*log.Entry, 0, ws.MaxBufferSize)
+
+	return nil
+}
+
+// flushLoop периодически отправляет буферизированные записи на синхронизацию.
+func (ws *BufferedWriteSyncer) flushLoop() {
+	ticker := time.NewTicker(ws.FlushInterval)
+	defer func() {
+		ticker.Stop()
+		ws.wg.Done()
+	}()
+
+	for {
+		select {
+		case <-ticker.C:
+			_ = ws.Sync()
+		case <-ws.flushStop:
+			return
+		}
+	}
+}
+
+// syncLoop синхронизирует записи с log.Service.
+func (ws *BufferedWriteSyncer) syncLoop() {
+	defer ws.wg.Done()
+
+	for entries := range ws.syncQueue {
+		_ = ws.Service.Log(context.Background(), entries)
+	}
+}
diff --git a/log/zap/buffered_write_syncer_test.go b/log/zap/buffered_write_syncer_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..58e40098bb2370b7899d31fc42c2ea569ca81cf7
--- /dev/null
+++ b/log/zap/buffered_write_syncer_test.go
@@ -0,0 +1,143 @@
+package zap
+
+import (
+	"sync"
+	"testing"
+	"time"
+
+	"git.perx.ru/perxis/perxis-go/log"
+	logmocks "git.perx.ru/perxis/perxis-go/log/mocks"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/require"
+)
+
+func TestBufferedWriteSyncer_Write(t *testing.T) {
+	service := &logmocks.Service{}
+	service.On("Log", mock.Anything, mock.Anything).
+		Return(nil).
+		Run(func(args mock.Arguments) {
+			entries := args.Get(1).([]*log.Entry)
+			require.Equal(t, 2, len(entries))
+		}).
+		Once()
+
+	ws := &BufferedWriteSyncer{Service: service}
+
+	err := ws.Write(&log.Entry{Message: "first log message"})
+	require.NoError(t, err)
+
+	err = ws.Write(&log.Entry{Message: "second log message"})
+	require.NoError(t, err)
+
+	err = ws.Stop()
+	require.NoError(t, err)
+
+	service.AssertExpectations(t)
+}
+
+func TestBufferedWriteSyncer_Write_Concurrent(t *testing.T) {
+	service := &logmocks.Service{}
+	service.On("Log", mock.Anything, mock.Anything).
+		Return(nil).
+		Run(func(args mock.Arguments) {
+			entries := args.Get(1).([]*log.Entry)
+			require.Equal(t, 100, len(entries))
+		}).
+		Once()
+
+	ws := &BufferedWriteSyncer{Service: service}
+
+	var wg sync.WaitGroup
+	for i := 0; i < 100; i++ {
+		wg.Add(1)
+		go func(wg *sync.WaitGroup) {
+			defer wg.Done()
+
+			err := ws.Write(&log.Entry{Message: "log message"})
+			require.NoError(t, err)
+		}(&wg)
+	}
+
+	wg.Wait()
+
+	err := ws.Stop()
+	require.NoError(t, err)
+
+	service.AssertExpectations(t)
+}
+
+func TestBufferedWriteSyncer_Flush(t *testing.T) {
+	service := &logmocks.Service{}
+	service.On("Log", mock.Anything, mock.Anything).
+		Return(nil).
+		Run(func(args mock.Arguments) {
+			entries := args.Get(1).([]*log.Entry)
+			require.Equal(t, 10, len(entries))
+		}).
+		Times(10)
+
+	ws := &BufferedWriteSyncer{Service: service}
+
+	for i := 0; i < 10; i++ {
+		for j := 0; j < 10; j++ {
+			err := ws.Write(&log.Entry{Message: "log message"})
+			require.NoError(t, err)
+		}
+		err := ws.Sync()
+		require.NoError(t, err)
+	}
+
+	err := ws.Stop()
+	require.NoError(t, err)
+
+	service.AssertExpectations(t)
+}
+
+func TestBufferedWriteSyncer_MaxBufferSize(t *testing.T) {
+	service := &logmocks.Service{}
+	service.On("Log", mock.Anything, mock.Anything).
+		Return(nil).
+		Run(func(args mock.Arguments) {
+			entries := args.Get(1).([]*log.Entry)
+			assert.Equal(t, 10, len(entries))
+		}).
+		Times(10)
+
+	ws := &BufferedWriteSyncer{Service: service, MaxBufferSize: 10}
+
+	for i := 0; i < 100; i++ {
+		err := ws.Write(&log.Entry{Message: "log message"})
+		require.NoError(t, err)
+	}
+
+	err := ws.Stop()
+	require.NoError(t, err)
+
+	service.AssertExpectations(t)
+}
+
+func TestBufferedWriteSyncer_FlushInterval(t *testing.T) {
+	service := &logmocks.Service{}
+	service.On("Log", mock.Anything, mock.Anything).
+		Return(nil).
+		Run(func(args mock.Arguments) {
+			entries := args.Get(1).([]*log.Entry)
+			assert.Equal(t, 10, len(entries))
+		}).
+		Once()
+
+	ws := &BufferedWriteSyncer{Service: service, FlushInterval: time.Second}
+
+	for j := 0; j < 10; j++ {
+		err := ws.Write(&log.Entry{Message: "log message"})
+		require.NoError(t, err)
+	}
+
+	time.Sleep(3 * time.Second) // ждем, пока сработает интервал
+
+	err := ws.Stop()
+	require.NoError(t, err)
+
+	service.AssertExpectations(t)
+}
diff --git a/log/zap/core.go b/log/zap/core.go
new file mode 100644
index 0000000000000000000000000000000000000000..f2b220eab78a6cd2416408385b521c743bcefb94
--- /dev/null
+++ b/log/zap/core.go
@@ -0,0 +1,88 @@
+package zap
+
+import (
+	oid "git.perx.ru/perxis/perxis-go/id"
+	"git.perx.ru/perxis/perxis-go/log"
+	"git.perx.ru/perxis/perxis-go/pkg/id"
+	"go.uber.org/zap"
+	"go.uber.org/zap/zapcore"
+)
+
+// WriteSyncer отвечает за хранение и синхронизацию log.Entry
+type WriteSyncer interface {
+	Write(entry *log.Entry) error
+	Sync() error
+}
+
+// Core кодирует zapcore.Entry в log.Entry и отправляет их в WriteSyncer
+type Core struct {
+	zapcore.LevelEnabler
+
+	writeSyncer WriteSyncer
+	fields      []zap.Field
+}
+
+func NewCore(writeSyncer WriteSyncer) *Core {
+	return &Core{
+		LevelEnabler: zapcore.InfoLevel,
+		writeSyncer:  writeSyncer,
+	}
+}
+
+func (core *Core) With(fields []zapcore.Field) zapcore.Core {
+	return &Core{
+		LevelEnabler: core.LevelEnabler,
+		writeSyncer:  core.writeSyncer,
+		fields:       append(core.fields, fields...),
+	}
+}
+
+func (core *Core) Check(entry zapcore.Entry, checkedEntry *zapcore.CheckedEntry) *zapcore.CheckedEntry {
+	if core.Enabled(entry.Level) {
+		return checkedEntry.AddCore(entry, core)
+	}
+	return checkedEntry
+}
+
+func (core *Core) Write(entry zapcore.Entry, fields []zapcore.Field) error {
+	return core.writeSyncer.Write(core.getEntry(entry, fields))
+}
+
+func (core *Core) Sync() error {
+	return core.writeSyncer.Sync()
+}
+
+func (core *Core) getEntry(entry zapcore.Entry, fields []zapcore.Field) *log.Entry {
+	if len(core.fields) > 0 {
+		fields = append(fields, core.fields...)
+	}
+
+	enc := zapcore.NewMapObjectEncoder()
+	for _, field := range fields {
+		field.AddTo(enc)
+	}
+
+	ent := &log.Entry{
+		ID:        id.GenerateNewID(),
+		Timestamp: entry.Time,
+		LogLevel:  log.Level(entry.Level),
+		Message:   entry.Message,
+	}
+
+	ent.Category, _ = enc.Fields["category"].(string)
+	ent.Component, _ = enc.Fields["component"].(string)
+	ent.Event, _ = enc.Fields["event"].(string)
+	ent.ObjectID, _ = enc.Fields["object"].(*oid.ObjectId)
+	ent.CallerID, _ = enc.Fields["caller"].(*oid.ObjectId)
+	ent.Attr = enc.Fields["attr"]
+
+	if tags, ok := enc.Fields["tags"].([]any); ok {
+		for _, item := range tags {
+			if tag, ok := item.(string); ok {
+				ent.Tags = append(ent.Tags, tag)
+			}
+		}
+	}
+
+	return ent
+}
diff --git a/log/zap/core_test.go b/log/zap/core_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..f78916cb9faeb197179cebbaa608735d86ceddee
--- /dev/null
+++ b/log/zap/core_test.go
@@ -0,0 +1,64 @@
+package zap
+
+import (
+	"testing"
+
+	"git.perx.ru/perxis/perxis-go/id"
+	"git.perx.ru/perxis/perxis-go/log"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/zap"
+	"go.uber.org/zap/zapcore"
+)
+
+func TestCore_getEntry(t *testing.T) {
+	core := NewCore(nil)
+
+	tests := []struct {
+		name  string
+		input struct {
+			entry  zapcore.Entry
+			fields []zapcore.Field
+		}
+		want *log.Entry
+	}{
+		{
+			name: "simple",
+			input: struct {
+				entry  zapcore.Entry
+				fields []zapcore.Field
+			}{
+				entry: zapcore.Entry{Level: zapcore.InfoLevel, Message: "создан элемент коллекции"},
+				fields: []zapcore.Field{
+					zap.String("key", "val"), // будет проигнорировано
+					Category("create"),
+					Component("Items.Service"),
+					Event("Items.Create"),
+					Object("/spaces/WPNN/envs/9VGP/cols/GxNv/items/W0fl"),
+					Caller("/users/PHVz"),
+					Attr("any"),
+					Tags("tag1", "tag2", "tag3"),
+				},
+			},
+			want: &log.Entry{
+				LogLevel:  log.Level(zapcore.InfoLevel),
+				Message:   "создан элемент коллекции",
+				Category:  "create",
+				Component: "Items.Service",
+				Event:     "Items.Create",
+				ObjectID:  id.MustObjectId("/spaces/WPNN/envs/9VGP/cols/GxNv/items/W0fl"),
+				CallerID:  id.MustObjectId("/users/PHVz"),
+				Attr:      "any",
+				Tags:      []string{"tag1", "tag2", "tag3"},
+			},
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			got := core.getEntry(tc.input.entry, tc.input.fields)
+			got.ID = tc.want.ID               // игнорируем ID
+			got.Timestamp = tc.want.Timestamp // игнорируем Timestamp
+			require.Equal(t, tc.want, got)
+		})
+	}
+}
diff --git a/log/zap/example_test.go b/log/zap/example_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..d99b83c23b4ae05eaa091d2c8e96e1820b90d6fb
--- /dev/null
+++ b/log/zap/example_test.go
@@ -0,0 +1,96 @@
+package zap
+
+import (
+	"context"
+	"reflect"
+	"slices"
+	"testing"
+
+	"git.perx.ru/perxis/perxis-go/id"
+	"git.perx.ru/perxis/perxis-go/log"
+	logmocks "git.perx.ru/perxis/perxis-go/log/mocks"
+	"git.perx.ru/perxis/perxis-go/pkg/auth"
+	"git.perx.ru/perxis/perxis-go/pkg/items"
+	"git.perx.ru/perxis/perxis-go/pkg/users"
+	usersmocks "git.perx.ru/perxis/perxis-go/pkg/users/mocks"
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/zap"
+	"go.uber.org/zap/zapcore"
+)
+
+func TestExample(t *testing.T) {
+	item := items.NewItem("WPNN", "9VGP", "GxNv", "W0fl", nil, nil)
+	user := &users.User{ID: "294de355"}
+
+	wantEntries := []*log.Entry{
+		{
+			LogLevel:  log.Level(zapcore.InfoLevel),
+			Message:   "Successfully created",
+			Component: "Items",
+			Event:     items.EventCreateItem,
+			ObjectID:  id.MustObjectId(item),
+			CallerID:  id.MustObjectId(user),
+			Tags:      []string{"tag1", "tag2", "tag3"},
+		},
+		{
+			LogLevel:  log.Level(zapcore.WarnLevel),
+			Message:   "Successfully updated",
+			Component: "Items",
+			Event:     items.EventUpdateItem,
+			ObjectID:  id.MustObjectId(item),
+			CallerID:  id.MustObjectId(user),
+			Attr:      map[string]map[string]any{"title": {"old": "old title", "new": "new title"}},
+		},
+	}
+
+	service := &logmocks.Service{}
+	service.On("Log", mock.Anything, mock.Anything).
+		Return(nil).
+		Run(func(args mock.Arguments) {
+			entries := args.Get(1).([]*log.Entry)
+			require.True(t, slices.EqualFunc(wantEntries, entries, func(wantEntry, gotEntry *log.Entry) bool {
+				require.NotEmpty(t, gotEntry.ID)
+				require.NotEmpty(t, gotEntry.Timestamp)
+				gotEntry.ID = wantEntry.ID               // игнорируем ID
+				gotEntry.Timestamp = wantEntry.Timestamp // игнорируем Timestamp
+				return reflect.DeepEqual(wantEntry, gotEntry)
+			}))
+		}).
+		Once()
+
+	usersService := &usersmocks.Users{}
+	usersService.On("GetByIdentity", mock.Anything, "74d90aaf").Return(user, nil).Once()
+
+	factory := auth.PrincipalFactory{Users: usersService}
+
+	ws := &BufferedWriteSyncer{Service: service}
+	logger := zap.New(NewCore(ws))
+
+	// Пример отправки логов для сервиса Items
+	{
+		logger := logger.With(Component("Items"))
+		ctx := auth.WithPrincipal(context.Background(), factory.User("74d90aaf"))
+
+		// Отправка лога при создании item
+		logger.Info("Successfully created",
+			Event(items.EventCreateItem),
+			Object(item),
+			CallerFromContext(ctx),
+			Tags("tag1", "tag2", "tag3"),
+		)
+
+		// Отправка лога при обновлении item
+		logger.Warn("Successfully updated",
+			Event(items.EventUpdateItem),
+			Object(item),
+			CallerFromContext(ctx),
+			Attr(map[string]map[string]any{"title": {"old": "old title", "new": "new title"}}),
+		)
+	}
+
+	err := ws.Stop()
+	require.NoError(t, err)
+
+	service.AssertExpectations(t)
+}
diff --git a/log/zap/field.go b/log/zap/field.go
index e0de88be61059c325821ff68afe0bcbc7b88b6ba..acc6932d5fab8b2975d09998557e6ed41197010d 100644
--- a/log/zap/field.go
+++ b/log/zap/field.go
@@ -2,78 +2,72 @@ package zap
 
 import (
 	"context"
-	"fmt"
 
 	"git.perx.ru/perxis/perxis-go/id"
+	_ "git.perx.ru/perxis/perxis-go/id/system" // регистрируем обработчики для системных объектов
 	"git.perx.ru/perxis/perxis-go/pkg/auth"
 	"go.uber.org/zap"
-	"go.uber.org/zap/zapcore"
 )
 
-const (
-	unknownObject = "unknown"
-	unknownCaller = "unknown"
-)
-
-func Category(category string) zapcore.Field {
+func Category(category string) zap.Field {
+	if category == "" {
+		return zap.Skip()
+	}
 	return zap.String("category", category)
 }
 
-func Component(component string) zapcore.Field {
+func Component(component string) zap.Field {
+	if component == "" {
+		return zap.Skip()
+	}
 	return zap.String("component", component)
 }
 
-func Event(event string) zapcore.Field {
+func Event(event string) zap.Field {
+	if event == "" {
+		return zap.Skip()
+	}
 	return zap.String("event", event)
 }
 
-// Object возвращает поле и устанавливает передаваемый аргумент в качестве идентификатора объекта в формате ObjectID.
-// Поддерживаемые типы: string, fmt.Stringer.
-// Если передан аргумент другого типа, будет произведена попытка привести переданное значение к ObjectID.
-func Object(v any) zapcore.Field {
-	var object = unknownObject
-	switch value := v.(type) {
-	case string:
-		object = value
-	case fmt.Stringer:
-		object = value.String()
-	default:
-		oid, err := id.FromObject(v)
-		if err == nil {
-			object = oid.String()
-		}
+// Object возвращает поле и устанавливает передаваемый аргумент в качестве идентификатора объекта в формате ObjectId.
+// Поддерживает типы в формате ObjectId: id.Descriptor, string, map[string]any, системные объекты.
+func Object(v any) zap.Field {
+	oid, err := id.NewObjectId(v)
+	if err != nil {
+		return zap.Skip()
 	}
-	return zap.String("object", object)
+	return zap.Reflect("object", oid)
 }
 
-// Caller возвращает поле и устанавливает передаваемый аргумент в качестве "вызывающего" в формате ObjectID.
-// Поддерживаемые типы: string, fmt.Stringer.
-// Если передан аргумент другого типа, будет произведена попытка привести переданное значение к ObjectID.
-func Caller(v any) zapcore.Field {
-	var caller = unknownCaller
-	switch value := v.(type) {
-	case string:
-		caller = value
-	case fmt.Stringer:
-		caller = value.String()
-	default:
-		oid, err := id.FromObject(v)
-		if err == nil {
-			caller = oid.String()
-		}
+// Caller возвращает поле и устанавливает передаваемый аргумент в качестве "вызывающего" в формате ObjectId.
+// Поддерживает типы в формате ObjectId: id.Descriptor, string, map[string]any, системные объекты.
+func Caller(v any) zap.Field {
+	oid, err := id.NewObjectId(v)
+	if err != nil {
+		return zap.Skip()
 	}
-	return zap.String("caller", caller)
+	return zap.Reflect("caller", oid)
 }
 
-// CallerFromContext извлекает auth.Principal из контекста и устанавливает его в качестве "вызывающего" в формате ObjectID.
-func CallerFromContext(ctx context.Context) zapcore.Field {
+// CallerFromContext извлекает auth.Principal из контекста и устанавливает его в качестве "вызывающего" в формате Object.
+func CallerFromContext(ctx context.Context) zap.Field {
+	if ctx == nil {
+		return zap.Skip()
+	}
 	return Caller(auth.GetPrincipal(ctx))
 }
 
-func Attr(attr any) zapcore.Field {
+func Attr(attr any) zap.Field {
+	if attr == nil {
+		return zap.Skip()
+	}
 	return zap.Any("attr", attr)
 }
 
-func Tags(tags ...string) zapcore.Field {
+func Tags(tags ...string) zap.Field {
+	if len(tags) == 0 {
+		return zap.Skip()
+	}
 	return zap.Strings("tags", tags)
 }
diff --git a/log/zap/field_test.go b/log/zap/field_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..643506bac023d51b25920ae6b5a3c84d511eb342
--- /dev/null
+++ b/log/zap/field_test.go
@@ -0,0 +1,185 @@
+package zap
+
+import (
+	"context"
+	"testing"
+
+	"git.perx.ru/perxis/perxis-go/id"
+	"git.perx.ru/perxis/perxis-go/pkg/auth"
+	"git.perx.ru/perxis/perxis-go/pkg/items"
+	"git.perx.ru/perxis/perxis-go/pkg/users"
+	"github.com/stretchr/testify/assert"
+	"go.uber.org/zap"
+)
+
+func TestCategory(t *testing.T) {
+	tests := []struct {
+		name  string
+		field zap.Field
+		want  zap.Field
+	}{
+		{name: "ok", field: Category("update"), want: zap.String("category", "update")},
+		{name: "invalid", field: Category(""), want: zap.Skip()},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			assert.True(t, tc.want.Equals(tc.field))
+		})
+	}
+}
+
+func TestComponent(t *testing.T) {
+	tests := []struct {
+		name  string
+		field zap.Field
+		want  zap.Field
+	}{
+		{name: "ok", field: Component("Items"), want: zap.String("component", "Items")},
+		{name: "invalid", field: Component(""), want: zap.Skip()},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			assert.True(t, tc.want.Equals(tc.field))
+		})
+	}
+}
+
+func TestEvent(t *testing.T) {
+	tests := []struct {
+		name  string
+		field zap.Field
+		want  zap.Field
+	}{
+		{name: "ok", field: Event("items.create"), want: zap.String("event", "items.create")},
+		{name: "invalid", field: Event(""), want: zap.Skip()},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			assert.True(t, tc.want.Equals(tc.field))
+		})
+	}
+}
+
+func TestObjectID(t *testing.T) {
+	item := &items.Item{
+		ID:           "c4ca4238a0b923820dcc509a6f75849b",
+		SpaceID:      "c81e728d9d4c2f636f067f89cc14862c",
+		EnvID:        "eccbc87e4b5ce2fe28308fd9f2a7baf3",
+		CollectionID: "a87ff679a2f3e71d9181a67b7542122c",
+	}
+
+	oid := id.MustObjectId(item)
+	itemId := id.NewItemId(*item)
+
+	tests := []struct {
+		name  string
+		field zap.Field
+		want  zap.Field
+	}{
+		{name: "system object", field: Object(item), want: zap.Reflect("object", oid)},
+		{name: "object id", field: Object(itemId), want: zap.Reflect("object", oid)},
+		{name: "string", field: Object(oid.String()), want: zap.Reflect("object", oid)},
+		{name: "invalid", field: Object(nil), want: zap.Skip()},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			if tc.want.Equals(zap.Skip()) {
+				assert.True(t, tc.want.Equals(tc.field))
+				return
+			}
+			assert.Equal(t, tc.want.Interface.(id.Descriptor).String(), tc.field.Interface.(id.Descriptor).String())
+		})
+	}
+}
+
+func TestCallerID(t *testing.T) {
+	user := &users.User{
+		ID: "c4ca4238a0b923820dcc509a6f75849b",
+	}
+
+	oid := id.MustObjectId(user)
+	userId := id.NewUserId(*user)
+
+	tests := []struct {
+		name  string
+		field zap.Field
+		want  zap.Field
+	}{
+		{name: "system object", field: Caller(user), want: zap.Reflect("caller", oid)},
+		{name: "object id", field: Caller(userId), want: zap.Reflect("caller", oid)},
+		{name: "string", field: Caller(oid.String()), want: zap.Reflect("caller", oid)},
+		{name: "invalid", field: Caller(nil), want: zap.Skip()},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			if tc.want.Equals(zap.Skip()) {
+				assert.True(t, tc.want.Equals(tc.field))
+				return
+			}
+			assert.Equal(t, tc.want.Interface.(id.Descriptor).String(), tc.field.Interface.(id.Descriptor).String())
+		})
+	}
+}
+
+func TestCallerIDFromContext(t *testing.T) {
+	ctx := auth.WithSystem(context.Background())
+	oid := id.MustObjectId(auth.GetPrincipal(ctx))
+
+	tests := []struct {
+		name  string
+		field zap.Field
+		want  zap.Field
+	}{
+		{name: "ok", field: CallerFromContext(ctx), want: zap.Reflect("caller", oid)},
+		{name: "invalid", field: CallerFromContext(nil), want: zap.Skip()},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			if tc.want.Equals(zap.Skip()) {
+				assert.True(t, tc.want.Equals(tc.field))
+				return
+			}
+			assert.Equal(t, tc.want.Interface.(id.Descriptor).String(), tc.field.Interface.(id.Descriptor).String())
+		})
+	}
+}
+
+func TestAttr(t *testing.T) {
+	tests := []struct {
+		name  string
+		field zap.Field
+		want  zap.Field
+	}{
+		{name: "ok", field: Attr(map[string]string{"a": "b"}), want: zap.Reflect("attr", map[string]string{"a": "b"})},
+		{name: "invalid", field: Attr(nil), want: zap.Skip()},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			assert.True(t, tc.want.Equals(tc.field))
+		})
+	}
+}
+
+func TestTags(t *testing.T) {
+	tests := []struct {
+		name  string
+		field zap.Field
+		want  zap.Field
+	}{
+		{name: "ok", field: Tags("a", "b", "c"), want: zap.Strings("tags", []string{"a", "b", "c"})},
+		{name: "invalid", field: Tags(nil...), want: zap.Skip()},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			assert.True(t, tc.want.Equals(tc.field))
+		})
+	}
+}