diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 00000000..e6ec0297
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,23 @@
+---
+name: 🐛 Bug Report
+about: Report a reproducible bug in the current release of DSync
+---
+
+### Environment
+* DSync version:  <!-- Example: 0.1.0 -->
+* Python version  <!-- Example: 3.7.7 -->
+
+<!-- What happened instead? -->
+### Observed Behavior
+
+<!-- What did you expect to happen? -->
+### Expected Behavior
+
+<!--
+    Describe in detail the exact steps that someone else can take to reproduce
+    this bug using the current release.
+-->
+### Steps to Reproduce
+1.
+2.
+3.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 00000000..00f22cf5
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,21 @@
+---
+name: ✨ Feature Request
+about: Propose a new feature or enhancement
+---
+
+### Environment
+* DSync version:  <!-- Example: 0.1.0 -->
+
+<!--
+    Describe in detail the new functionality you are proposing.
+-->
+### Proposed Functionality
+
+
+<!--
+    Convey an example use case for your proposed feature. Write from the
+    perspective of a user who would benefit from the proposed
+    functionality and describe how.
+--->
+### Use Case
+
diff --git a/dsync/__init__.py b/dsync/__init__.py
index a73548c2..03037f52 100644
--- a/dsync/__init__.py
+++ b/dsync/__init__.py
@@ -15,10 +15,10 @@
 from collections.abc import Iterable as ABCIterable, Mapping as ABCMapping
 import enum
 from inspect import isclass
-import logging
 from typing import ClassVar, Iterable, List, Mapping, MutableMapping, Optional, Tuple, Type, Union
 
 from pydantic import BaseModel
+import structlog  # type: ignore
 
 from .diff import Diff, DiffElement
 from .utils import intersection
@@ -32,7 +32,7 @@
     ObjectNotFound,
 )
 
-logger = logging.getLogger(__name__)
+_logger = structlog.get_logger()
 
 
 class DSyncFlags(enum.Flag):
@@ -321,6 +321,9 @@ class DSync:
     # modelname1 = MyModelClass1
     # modelname2 = MyModelClass2
 
+    type: ClassVar[Optional[str]] = None
+    """Type of the object, will default to the name of the class if not provided."""
+
     top_level: ClassVar[List[str]] = []
     """List of top-level modelnames to begin from when diffing or synchronizing."""
 
@@ -330,12 +333,20 @@ class DSync:
     `self._data[modelname][unique_id] == model_instance`
     """
 
-    def __init__(self):
+    def __init__(self, name=None):
         """Generic initialization function.
 
         Subclasses should be careful to call super().__init__() if they override this method.
         """
         self._data = defaultdict(dict)
+        self._log = _logger.new(dsync=self)
+
+        # If the type is not defined, use the name of the class as the default value
+        if self.type is None:
+            self.type = self.__class__.__name__
+
+        # If the name has not been provided, use the type as the name
+        self.name = name if name else self.type
 
     def __init_subclass__(cls):
         """Validate that references to specific DSyncModels use the correct modelnames.
@@ -349,6 +360,15 @@ def __init_subclass__(cls):
                     f'Incorrect field name - {value.__name__} has type name "{value.get_type()}", not "{name}"'
                 )
 
+    def __str__(self):
+        """String representation of a DSync."""
+        if self.type != self.name:
+            return f'{self.type} "{self.name}"'
+        return self.type
+
+    def __repr__(self):
+        return f"<{str(self)}>"
+
     def load(self):
         """Load all desired data from whatever backend data source into this instance."""
         # No-op in this generic class
@@ -365,12 +385,13 @@ def sync_from(self, source: "DSync", diff_class: Type[Diff] = Diff, flags: DSync
             diff_class (class): Diff or subclass thereof to use to calculate the diffs to use for synchronization
             flags (DSyncFlags): Flags influencing the behavior of this sync.
         """
+        log = self._log.bind(src=source, dst=self, flags=flags).unbind("dsync")
         diff = self.diff_from(source, diff_class=diff_class, flags=flags)
 
-        logger.info("Beginning sync")
+        log.info("Beginning sync")
         for child in diff.get_children():
-            self._sync_from_diff_element(child, flags=flags)
-        logger.info("Sync complete")
+            self._sync_from_diff_element(child, flags=flags, logger=log)
+        log.info("Sync complete")
 
     def sync_to(self, target: "DSync", diff_class: Type[Diff] = Diff, flags: DSyncFlags = DSyncFlags.NONE):
         """Synchronize data from the current DSync object into the given target DSync object.
@@ -383,7 +404,11 @@ def sync_to(self, target: "DSync", diff_class: Type[Diff] = Diff, flags: DSyncFl
         target.sync_from(self, diff_class=diff_class, flags=flags)
 
     def _sync_from_diff_element(
-        self, element: DiffElement, flags: DSyncFlags = DSyncFlags.NONE, parent_model: DSyncModel = None,
+        self,
+        element: DiffElement,
+        flags: DSyncFlags = DSyncFlags.NONE,
+        logger: structlog.BoundLogger = None,
+        parent_model: DSyncModel = None,
     ):
         """Synchronize a given DiffElement (and its children, if any) into this DSync.
 
@@ -392,49 +417,53 @@ def _sync_from_diff_element(
         Args:
             element: DiffElement to synchronize diffs from
             flags (DSyncFlags): Flags influencing the behavior of this sync.
+            logger: Parent logging context
             parent_model: Parent object to update (`add_child`/`remove_child`) if the sync creates/deletes an object.
         """
         # pylint: disable=too-many-branches
         # GFM: I made a few attempts at refactoring this to reduce the branching, but found that it was less readable.
         # So let's live with the slightly too high number of branches (14/12) for now.
+        log = logger or self._log
         object_class = getattr(self, element.type)
         obj = self.get(object_class, element.keys)
-        attrs = (
-            {attr_key: element.source_attrs[attr_key] for attr_key in element.get_attrs_keys()}
-            if element.source_attrs is not None
-            else {}
+        # Get the attributes that actually differ between source and dest
+        diffs = element.get_attrs_diffs()
+        log = log.bind(
+            action=element.action,
+            model=object_class.get_type(),
+            unique_id=object_class.create_unique_id(**element.keys),
+            diffs=diffs,
         )
 
         try:
             if element.action == "create":
+                log.debug("Attempting object creation")
                 if obj:
                     raise ObjectNotCreated(f"Failed to create {object_class.get_type()} {element.keys} - it exists!")
-                logger.info("Creating %s %s (%s)", object_class.get_type(), element.keys, attrs)
-                obj = object_class.create(dsync=self, ids=element.keys, attrs=attrs)
+                obj = object_class.create(dsync=self, ids=element.keys, attrs={key: diffs[key]["src"] for key in diffs})
+                log.info("Created successfully", status="success")
             elif element.action == "update":
+                log.debug("Attempting object update")
                 if not obj:
                     raise ObjectNotUpdated(f"Failed to update {object_class.get_type()} {element.keys} - not found!")
-                logger.info("Updating %s %s with %s", object_class.get_type(), element.keys, attrs)
-                obj = obj.update(attrs=attrs)
+                obj = obj.update(attrs={key: diffs[key]["src"] for key in diffs})
+                log.info("Updated successfully", status="success")
             elif element.action == "delete":
+                log.debug("Attempting object deletion")
                 if not obj:
                     raise ObjectNotDeleted(f"Failed to delete {object_class.get_type()} {element.keys} - not found!")
-                logger.info("Deleting %s %s", object_class.get_type(), element.keys)
                 obj = obj.delete()
+                log.info("Deleted successfully", status="success")
         except ObjectCrudException as exception:
-            logger.error(
-                "Error during %s of %s %s (%s): %s",
-                element.action,
-                object_class.get_type(),
-                element.keys,
-                attrs,
-                exception,
-            )
+            log.error(str(exception), status="error")
             if not flags & DSyncFlags.CONTINUE_ON_FAILURE:
                 raise
+        else:
+            if obj is None:
+                log.warning("Non-fatal failure encountered", status="failure")
 
         if obj is None:
-            logger.warning("Not syncing children of %s %s", element.type, element.keys)
+            log.warning("Not syncing children")
             return
 
         if element.action == "create":
@@ -447,7 +476,7 @@ def _sync_from_diff_element(
                 parent_model.remove_child(obj)
 
         for child in element.get_children():
-            self._sync_from_diff_element(child, flags=flags, parent_model=obj)
+            self._sync_from_diff_element(child, flags=flags, parent_model=obj, logger=logger)
 
     # ------------------------------------------------------------------------------
     # Diff calculation and construction
@@ -461,20 +490,25 @@ def diff_from(self, source: "DSync", diff_class: Type[Diff] = Diff, flags: DSync
             diff_class (class): Diff or subclass thereof to use for diff calculation and storage.
             flags (DSyncFlags): Flags influencing the behavior of this diff operation.
         """
-        logger.info("Beginning diff")
+        log = self._log.bind(src=source, dst=self, flags=flags).unbind("dsync")
+        log.info("Beginning diff")
         diff = diff_class()
 
         for obj_type in intersection(self.top_level, source.top_level):
 
             diff_elements = self._diff_objects(
-                source=source.get_all(obj_type), dest=self.get_all(obj_type), source_root=source, flags=flags,
+                source=source.get_all(obj_type),
+                dest=self.get_all(obj_type),
+                source_root=source,
+                flags=flags,
+                logger=log,
             )
 
             for diff_element in diff_elements:
                 diff.add(diff_element)
 
         # Notify the diff that it has been fully populated, in case it wishes to print, save to a file, etc.
-        logger.info("Diff complete")
+        log.info("Diff complete")
         diff.complete()
         return diff
 
@@ -488,12 +522,13 @@ def diff_to(self, target: "DSync", diff_class: Type[Diff] = Diff, flags: DSyncFl
         """
         return target.diff_from(self, diff_class=diff_class, flags=flags)
 
-    def _diff_objects(
+    def _diff_objects(  # pylint: disable=too-many-arguments
         self,
         source: Iterable[DSyncModel],
         dest: Iterable[DSyncModel],
         source_root: "DSync",
         flags: DSyncFlags = DSyncFlags.NONE,
+        logger: structlog.BoundLogger = None,
     ) -> List[DiffElement]:
         """Generate a list of DiffElement between the given lists of objects.
 
@@ -504,6 +539,7 @@ def _diff_objects(
             dest: DSyncModel instances retrieved from this DSync instance
             source_root (DSync): The other DSync object being diffed against (owner of the `source` models, if any)
             flags (DSyncFlags): Flags influencing the behavior of this diff operation.
+            logger: Parent logging context
 
         Raises:
             TypeError: if the source and dest args are not the same type, or if that type is unsupported
@@ -527,26 +563,35 @@ def _diff_objects(
         self._validate_objects_for_diff(combined_dict)
 
         for uid in combined_dict:
+            log = logger or self._log
             src_obj, dst_obj = combined_dict[uid]
-
-            if flags & DSyncFlags.SKIP_UNMATCHED_SRC and not dst_obj:
-                logger.debug("Skipping unmatched source object {src_obj}")
-                continue
-            if flags & DSyncFlags.SKIP_UNMATCHED_DST and not src_obj:
-                logger.debug("Skipping unmatched dest object {dst_obj}")
-                continue
-
+            if not src_obj and not dst_obj:
+                # Should never happen
+                raise RuntimeError(f"UID {uid} is in combined_dict but has neither src_obj nor dst_obj??")
             if src_obj:
+                log = log.bind(model=src_obj.get_type(), unique_id=src_obj.get_unique_id())
+                if flags & DSyncFlags.SKIP_UNMATCHED_SRC and not dst_obj:
+                    log.debug("Skipping unmatched source object")
+                    continue
                 diff_element = DiffElement(
-                    obj_type=src_obj.get_type(), name=src_obj.get_shortname(), keys=src_obj.get_identifiers()
+                    obj_type=src_obj.get_type(),
+                    name=src_obj.get_shortname(),
+                    keys=src_obj.get_identifiers(),
+                    source_name=source_root.name,
+                    dest_name=self.name,
                 )
             elif dst_obj:
+                log = log.bind(model=dst_obj.get_type(), unique_id=dst_obj.get_unique_id())
+                if flags & DSyncFlags.SKIP_UNMATCHED_DST and not src_obj:
+                    log.debug("Skipping unmatched dest object")
+                    continue
                 diff_element = DiffElement(
-                    obj_type=dst_obj.get_type(), name=dst_obj.get_shortname(), keys=dst_obj.get_identifiers()
+                    obj_type=dst_obj.get_type(),
+                    name=dst_obj.get_shortname(),
+                    keys=dst_obj.get_identifiers(),
+                    source_name=source_root.name,
+                    dest_name=self.name,
                 )
-            else:
-                # Should be unreachable
-                raise RuntimeError(f"UID {uid} is in combined_dict but has neither src_obj nor dst_obj??")
 
             if src_obj:
                 diff_element.add_attrs(source=src_obj.get_attrs(), dest=None)
@@ -554,7 +599,7 @@ def _diff_objects(
                 diff_element.add_attrs(source=None, dest=dst_obj.get_attrs())
 
             # Recursively diff the children of src_obj and dst_obj and attach the resulting diffs to the diff_element
-            self._diff_child_objects(diff_element, src_obj, dst_obj, source_root, flags)
+            self._diff_child_objects(diff_element, src_obj, dst_obj, source_root, flags=flags, logger=logger)
 
             diffs.append(diff_element)
 
@@ -589,6 +634,7 @@ def _diff_child_objects(  # pylint: disable=too-many-arguments
         dst_obj: Optional[DSyncModel],
         source_root: "DSync",
         flags: DSyncFlags,
+        logger: structlog.BoundLogger,
     ):
         """For all children of the given DSyncModel pair, diff them recursively, adding diffs to the given diff_element.
 
@@ -620,7 +666,7 @@ def _diff_child_objects(  # pylint: disable=too-many-arguments
             dst_objs = self.get_by_uids(getattr(dst_obj, child_fieldname), child_type) if dst_obj else []
 
             for child_diff_element in self._diff_objects(
-                source=src_objs, dest=dst_objs, source_root=source_root, flags=flags
+                source=src_objs, dest=dst_objs, source_root=source_root, flags=flags, logger=logger,
             ):
                 diff_element.add_child(child_diff_element)
 
diff --git a/dsync/diff.py b/dsync/diff.py
index 1838dccc..d53905ce 100644
--- a/dsync/diff.py
+++ b/dsync/diff.py
@@ -14,7 +14,7 @@
 """
 
 from functools import total_ordering
-from typing import Iterator, Iterable, Optional
+from typing import Any, Iterator, Iterable, Mapping, Optional
 
 from .exceptions import ObjectAlreadyExists
 from .utils import intersection, OrderedDefaultDict
@@ -108,10 +108,12 @@ def print_detailed(self, indent: int = 0):
 
 
 @total_ordering
-class DiffElement:
+class DiffElement:  # pylint: disable=too-many-instance-attributes
     """DiffElement object, designed to represent a single item/object that may or may not have any diffs."""
 
-    def __init__(self, obj_type: str, name: str, keys: dict):
+    def __init__(
+        self, obj_type: str, name: str, keys: dict, source_name: str = "source", dest_name: str = "dest"
+    ):  # pylint: disable=too-many-arguments
         """Instantiate a DiffElement.
 
         Args:
@@ -119,6 +121,8 @@ def __init__(self, obj_type: str, name: str, keys: dict):
             name (str): Human-readable name of the object being described, as in DSyncModel.get_shortname().
                 This name must be unique within the context of the Diff that is the direct parent of this DiffElement.
             keys (dict): Primary keys and values uniquely describing this object, as in DSyncModel.get_identifiers().
+            source_name (str): Name of the source DSync object
+            dest_name (str): Name of the destination DSync object
         """
         if not isinstance(obj_type, str):
             raise ValueError(f"obj_type must be a string (not {type(obj_type)})")
@@ -129,6 +133,8 @@ def __init__(self, obj_type: str, name: str, keys: dict):
         self.type = obj_type
         self.name = name
         self.keys = keys
+        self.source_name = source_name
+        self.dest_name = dest_name
         # Note: *_attrs == None if no target object exists; it'll be an empty dict if it exists but has no _attributes
         self.source_attrs: Optional[dict] = None
         self.dest_attrs: Optional[dict] = None
@@ -159,7 +165,7 @@ def __eq__(self, other):
 
     def __str__(self):
         """Basic string representation of a DiffElement."""
-        return f"{self.type} : {self.name} : {self.keys} : {self.source_attrs} : {self.dest_attrs}"
+        return f'{self.type} "{self.name}" : {self.keys} : {self.source_name} → {self.dest_name} : {self.get_attrs_diffs()}'
 
     @property
     def action(self) -> Optional[str]:
@@ -206,6 +212,26 @@ def get_attrs_keys(self) -> Iterable[str]:
             return self.source_attrs.keys()
         return []
 
+    # The below would be more accurate but typing.Literal is only in Python 3.8 and later
+    # def get_attrs_diffs(self) -> Mapping[str, Mapping[Literal["src", "dst"], Any]]:
+    def get_attrs_diffs(self) -> Mapping[str, Mapping[str, Any]]:
+        """Get the dict of actual attribute diffs between source_attrs and dest_attrs.
+
+        Returns:
+            dict: of the form `{key: {src: <value>, dst: <value>}, key2: ...}`
+        """
+        if self.source_attrs is not None and self.dest_attrs is not None:
+            return {
+                key: dict(src=self.source_attrs[key], dst=self.dest_attrs[key])
+                for key in self.get_attrs_keys()
+                if self.source_attrs[key] != self.dest_attrs[key]
+            }
+        if self.source_attrs is None and self.dest_attrs is not None:
+            return {key: dict(src=None, dst=self.dest_attrs[key]) for key in self.get_attrs_keys()}
+        if self.source_attrs is not None and self.dest_attrs is None:
+            return {key: dict(src=self.source_attrs[key], dst=None) for key in self.get_attrs_keys()}
+        return {}
+
     def add_child(self, element: "DiffElement"):
         """Attach a child object of type DiffElement.
 
@@ -250,14 +276,13 @@ def print_detailed(self, indent: int = 0):
         margin = " " * indent
 
         if self.source_attrs is None:
-            print(f"{margin}{self.type}: {self.name} MISSING in SOURCE")
+            print(f"{margin}{self.type}: {self.name} MISSING in {self.source_name}")
         elif self.dest_attrs is None:
-            print(f"{margin}{self.type}: {self.name} MISSING in DEST")
+            print(f"{margin}{self.type}: {self.name} MISSING in {self.dest_name}")
         else:
             print(f"{margin}{self.type}: {self.name}")
             # Only print attrs that have meaning in both source and dest
-            for attr in self.get_attrs_keys():
-                if self.source_attrs[attr] != self.dest_attrs[attr]:
-                    print(f"{margin}  {attr}   S({self.source_attrs[attr]})   D({self.dest_attrs[attr]})")
+            for attr, item in self.get_attrs_diffs().items():
+                print(f"{margin}  {attr}   {self.source_name}({item.get('src')})   {self.dest_name}({item.get('dst')})")
 
         self.child_diff.print_detailed(indent + 2)
diff --git a/dsync/logging.py b/dsync/logging.py
new file mode 100644
index 00000000..c2bed410
--- /dev/null
+++ b/dsync/logging.py
@@ -0,0 +1,37 @@
+"""Helpful APIs for setting up DSync logging."""
+
+import logging
+
+import structlog  # type: ignore
+
+
+def enable_console_logging(verbosity=0):
+    """Enable formatted logging to console with the specified verbosity.
+
+    See https://www.structlog.org/en/stable/development.html as a reference
+
+    Args:
+        verbosity (int): 0 for WARNING logs, 1 for INFO logs, 2 for DEBUG logs
+    """
+    if verbosity == 0:
+        logging.basicConfig(format="%(message)s", level=logging.WARNING)
+    elif verbosity == 1:
+        logging.basicConfig(format="%(message)s", level=logging.INFO)
+    else:
+        logging.basicConfig(format="%(message)s", level=logging.DEBUG)
+
+    structlog.configure(
+        processors=[
+            structlog.stdlib.filter_by_level,  # <-- added
+            structlog.stdlib.add_logger_name,
+            structlog.stdlib.add_log_level,
+            structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M.%S"),
+            structlog.processors.StackInfoRenderer(),
+            structlog.processors.format_exc_info,
+            structlog.dev.ConsoleRenderer(),
+        ],
+        context_class=dict,
+        logger_factory=structlog.stdlib.LoggerFactory(),
+        wrapper_class=structlog.stdlib.BoundLogger,
+        cache_logger_on_first_use=True,
+    )
diff --git a/examples/example1/README.md b/examples/example1/README.md
index cdf43dc7..89717039 100644
--- a/examples/example1/README.md
+++ b/examples/example1/README.md
@@ -22,6 +22,14 @@ c = BackendC()
 c.load()
 ```
 
+Configure verbosity of DSync's structured logging to console; the default is full verbosity (all logs including debugging)
+```python
+from dsync.logging import enable_console_logging
+enable_console_logging(verbosity=0)  # Show WARNING and ERROR logs only
+# enable_console_logging(verbosity=1)  # Also include INFO logs
+# enable_console_logging(verbosity=2)  # Also include DEBUG logs
+```
+
 Show the differences between A and B
 ```python
 diff_a_b = a.diff_to(b)
@@ -46,4 +54,4 @@ diff_a_b = a.diff_to(b)
 diff_a_b.print_detailed()
 ```
 
-> In the Device model, the role is not defined as an attribute so it's not shown when we are comparing the different objects, even if the value is different.
+> In the Device model, the `site_name` and `role` are not included in the `_attributes`, so they are not shown when we are comparing the different objects, even if the value is different.
diff --git a/examples/example1/backend_a.py b/examples/example1/backend_a.py
index 67ccb4fc..ac0a9f8d 100644
--- a/examples/example1/backend_a.py
+++ b/examples/example1/backend_a.py
@@ -11,7 +11,7 @@
     },
     "sfo": {
         "sfo-spine1": {"role": "spine", "interfaces": {"eth0": "Interface 0", "eth1": "Interface 1"}},
-        "sfo-spine2": {"role": "spine", "interfaces": {"eth0": "TBD", "eth1": "ddd"}},
+        "sfo-spine2": {"role": "spine", "interfaces": {"eth0": "TBD", "eth1": "ddd", "eth2": "Interface 2"}},
     },
 }
 
@@ -25,6 +25,8 @@ class BackendA(DSync):
 
     top_level = ["site"]
 
+    type = "Backend A"
+
     nb = None
 
     def load(self):
diff --git a/examples/example1/backend_b.py b/examples/example1/backend_b.py
index dd3553e7..68aa548c 100644
--- a/examples/example1/backend_b.py
+++ b/examples/example1/backend_b.py
@@ -5,6 +5,10 @@
 from models import Site, Device, Interface
 
 DATA = {
+    "atl": {
+        "atl-spine1": {"role": "spine", "interfaces": {"eth0": "Interface 0", "eth1": "Interface 1"}},
+        "atl-spine2": {"role": "spine", "interfaces": {"eth0": "Interface 0", "eth1": "Interface 1"}},
+    },
     "nyc": {
         "nyc-spine1": {"role": "spine", "interfaces": {"eth0": "Interface 0/0", "eth1": "Interface 1"}},
         "nyc-spine2": {"role": "spine", "interfaces": {"eth0": "Interface 0", "eth1": "Interface 1"}},
diff --git a/examples/example1/main.py b/examples/example1/main.py
index 8fabf85f..33fffc47 100644
--- a/examples/example1/main.py
+++ b/examples/example1/main.py
@@ -1,4 +1,9 @@
 """Main executable for DSync "example1"."""
+# pylint: disable=wrong-import-order
+
+import argparse
+
+from dsync.logging import enable_console_logging
 
 from backend_a import BackendA
 from backend_b import BackendB
@@ -21,22 +26,31 @@ def order_children_site(cls, children):
 
 def main():
     """Demonstrate DSync behavior using the example backends provided."""
-    # pylint: disable=invalid-name
+    parser = argparse.ArgumentParser("example1")
+    parser.add_argument("--verbosity", "-v", default=0, action="count")
+    args = parser.parse_args()
+    enable_console_logging(verbosity=args.verbosity)
 
-    a = BackendA()
-    a.load()
+    print("Initializing and loading Backend A...")
+    backend_a = BackendA(name="Backend-A")
+    backend_a.load()
 
-    b = BackendB()
-    b.load()
+    print("Initializing and loading Backend B...")
+    backend_b = BackendB(name="Backend-B")
+    backend_b.load()
 
-    c = BackendC()
-    c.load()
+    print("Initializing and loading Backend C...")
+    backend_c = BackendC()
+    backend_c.load()
 
-    diff_a_b = a.diff_to(b, diff_class=MyDiff)
+    print("Getting diffs from Backend A to Backend B...")
+    diff_a_b = backend_a.diff_to(backend_b, diff_class=MyDiff)
     diff_a_b.print_detailed()
 
-    a.sync_to(b)
-    a.diff_to(b).print_detailed()
+    print("Syncing changes from Backend A to Backend B...")
+    backend_a.sync_to(backend_b)
+    print("Getting updated diffs from Backend A to Backend B...")
+    backend_a.diff_to(backend_b).print_detailed()
 
 
 if __name__ == "__main__":
diff --git a/examples/example1/models.py b/examples/example1/models.py
index c91b0139..80020b3f 100644
--- a/examples/example1/models.py
+++ b/examples/example1/models.py
@@ -25,8 +25,8 @@ class Device(DSyncModel):
     _children = {"interface": "interfaces"}
 
     name: str
-    site_name: str
-    role: str
+    site_name: Optional[str]  # note that this attribute is NOT included in _attributes
+    role: Optional[str]  # note that this attribute is NOT included in _attributes
     interfaces: List = list()
 
 
diff --git a/poetry.lock b/poetry.lock
index 6129af59..f1df2fc1 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -122,9 +122,8 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 version = "7.1.2"
 
 [[package]]
-category = "dev"
+category = "main"
 description = "Cross-platform colored terminal text."
-marker = "sys_platform == \"win32\" or platform_system == \"Windows\""
 name = "colorama"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
@@ -551,6 +550,18 @@ pytest = ">=4.6"
 [package.extras]
 testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "pytest-xdist", "virtualenv"]
 
+[[package]]
+category = "dev"
+description = "Structured logging assertions"
+name = "pytest-structlog"
+optional = false
+python-versions = "*"
+version = "0.3"
+
+[package.dependencies]
+pytest = "*"
+structlog = "*"
+
 [[package]]
 category = "dev"
 description = "YAML parser and emitter for Python"
@@ -602,7 +613,7 @@ fixture = ["fixtures"]
 test = ["fixtures", "mock", "purl", "pytest", "sphinx", "testrepository (>=0.0.18)", "testtools"]
 
 [[package]]
-category = "dev"
+category = "main"
 description = "Python 2 and 3 compatibility utilities"
 name = "six"
 optional = false
@@ -640,6 +651,23 @@ pbr = ">=2.0.0,<2.1.0 || >2.1.0"
 python = "<3.8"
 version = ">=1.7.0"
 
+[[package]]
+category = "main"
+description = "Structured Logging for Python"
+name = "structlog"
+optional = false
+python-versions = "*"
+version = "20.1.0"
+
+[package.dependencies]
+six = "*"
+
+[package.extras]
+azure-pipelines = ["coverage", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "simplejson", "pytest-azurepipelines", "python-rapidjson", "pytest-asyncio"]
+dev = ["coverage", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "simplejson", "sphinx", "twisted", "pre-commit", "python-rapidjson", "pytest-asyncio"]
+docs = ["sphinx", "twisted"]
+tests = ["coverage", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "simplejson", "python-rapidjson", "pytest-asyncio"]
+
 [[package]]
 category = "dev"
 description = "Python Library for Tom's Obvious, Minimal Language"
@@ -733,7 +761,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
 testing = ["jaraco.itertools", "func-timeout"]
 
 [metadata]
-content-hash = "bea8d554afbb65ce4e439f8b4c026abcbef2a308a587a6f5bd1ad4dbdb8da4c6"
+content-hash = "df11dba646e7bbc5b3c4379563d115085f6e12a0438db1397b17d0782e4bfe37"
 python-versions = "^3.7"
 
 [metadata.files]
@@ -1008,6 +1036,10 @@ pytest-cov = [
     {file = "pytest-cov-2.10.1.tar.gz", hash = "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"},
     {file = "pytest_cov-2.10.1-py2.py3-none-any.whl", hash = "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191"},
 ]
+pytest-structlog = [
+    {file = "pytest-structlog-0.3.tar.gz", hash = "sha256:be01025a712f03e9e2ede3bfbd9bce7ce79d4b7449d426fc603bebfcb0b7e860"},
+    {file = "pytest_structlog-0.3-py2.py3-none-any.whl", hash = "sha256:c07ab9c89ee942ca5776ac81c746387dcb26d52c53466ee850daab67bb5b0281"},
+]
 pyyaml = [
     {file = "PyYAML-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f"},
     {file = "PyYAML-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76"},
@@ -1067,6 +1099,10 @@ stevedore = [
     {file = "stevedore-3.2.1-py3-none-any.whl", hash = "sha256:ddc09a744dc224c84ec8e8efcb70595042d21c97c76df60daee64c9ad53bc7ee"},
     {file = "stevedore-3.2.1.tar.gz", hash = "sha256:a34086819e2c7a7f86d5635363632829dab8014e5fd7be2454c7cba84ac7514e"},
 ]
+structlog = [
+    {file = "structlog-20.1.0-py2.py3-none-any.whl", hash = "sha256:8a672be150547a93d90a7d74229a29e765be05bd156a35cdcc527ebf68e9af92"},
+    {file = "structlog-20.1.0.tar.gz", hash = "sha256:7a48375db6274ed1d0ae6123c486472aa1d0890b08d314d2b016f3aa7f35990b"},
+]
 toml = [
     {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"},
     {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"},
diff --git a/pyproject.toml b/pyproject.toml
index 1c12d475..21eda1a2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,6 +7,8 @@ authors = ["Network to Code, LLC <info@networktocode.com>"]
 [tool.poetry.dependencies]
 python = "^3.7"
 pydantic = "^1.6.1"
+structlog = "^20.1.0"
+colorama = {version = "^0.4.3", optional = true}
 
 [tool.poetry.dev-dependencies]
 pytest = "^6.1.0"
@@ -22,6 +24,7 @@ flake8 = "^3.8.3"
 ipython = "^7.18.1"
 mypy = "^0.782"
 pytest-cov = "^2.10.1"
+pytest-structlog = "^0.3"
 
 [tool.poetry.scripts]
 dsync = 'dsync.cli:main'
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
index 65e84c8e..17410373 100644
--- a/tests/unit/conftest.py
+++ b/tests/unit/conftest.py
@@ -287,6 +287,8 @@ class BackendB(GenericBackend):
     device = DeviceB
     place = PlaceB
 
+    type = "Backend_B"
+
     DATA = {
         "nyc": {
             "nyc-spine1": {"role": "spine", "interfaces": {"eth0": "Interface 0/0", "eth1": "Interface 1"}},
@@ -313,7 +315,7 @@ def load(self):
 @pytest.fixture
 def backend_b():
     """Provide an instance of BackendB subclass of DSync."""
-    dsync = BackendB()
+    dsync = BackendB(name="backend-b")
     dsync.load()
     return dsync
 
diff --git a/tests/unit/test_diff_element.py b/tests/unit/test_diff_element.py
index 85e9589c..7ccf6550 100644
--- a/tests/unit/test_diff_element.py
+++ b/tests/unit/test_diff_element.py
@@ -10,6 +10,8 @@ def test_diff_element_empty():
     assert element.type == "interface"
     assert element.name == "eth0"
     assert element.keys == {"device_name": "device1", "name": "eth0"}
+    assert element.source_name == "source"
+    assert element.dest_name == "dest"
     assert element.source_attrs is None
     assert element.dest_attrs is None
     assert list(element.get_children()) == []
@@ -18,6 +20,11 @@ def test_diff_element_empty():
     assert not element.has_diffs(include_children=False)
     assert element.get_attrs_keys() == []
 
+    element2 = DiffElement(
+        "interface", "eth0", {"device_name": "device1", "name": "eth0"}, source_name="S1", dest_name="D1"
+    )
+    assert element2.source_name == "S1"
+    assert element2.dest_name == "D1"
     # TODO: test print_detailed
 
 
diff --git a/tests/unit/test_dsync.py b/tests/unit/test_dsync.py
index 4e0d10ef..4c7036ff 100644
--- a/tests/unit/test_dsync.py
+++ b/tests/unit/test_dsync.py
@@ -1,7 +1,5 @@
 """Unit tests for the DSync class."""
 
-import logging
-
 import pytest
 
 from dsync import DSync, DSyncModel, DSyncFlags
@@ -71,6 +69,8 @@ def test_generic_dsync_methods(generic_dsync, generic_dsync_model):
     )
     assert len(diff_elements) == 1
     assert not diff_elements[0].has_diffs()
+    assert diff_elements[0].source_name == "DSync"
+    assert diff_elements[0].dest_name == "DSync"
 
 
 def test_dsync_subclass_validation():
@@ -175,6 +175,19 @@ def test_dsync_subclass_methods_diff_sync(backend_a, backend_b):
     assert backend_a.get_by_uids(["nyc", "sfo"], "device") == []
 
 
+def test_dsync_subclass_methods_name_type(backend_a, backend_b):
+    """Test DSync name and type an actual concrete subclass.
+
+    backend_a is using the default name and type
+    backend_b is using a user defined name and type
+    """
+    assert backend_a.name == "BackendA"
+    assert backend_a.type == "BackendA"
+
+    assert backend_b.name == "backend-b"
+    assert backend_b.type == "Backend_B"
+
+
 def test_dsync_subclass_methods_crud(backend_a):
     """Test DSync CRUD APIs against a concrete subclass."""
     site_nyc_a = backend_a.get(Site, "nyc")
@@ -199,9 +212,8 @@ def test_dsync_subclass_methods_crud(backend_a):
         backend_a.remove(site_atl_a)
 
 
-def test_dsync_subclass_methods_sync_exceptions(caplog, error_prone_backend_a, backend_b):
+def test_dsync_subclass_methods_sync_exceptions(log, error_prone_backend_a, backend_b):
     """Test handling of exceptions during a sync."""
-    caplog.set_level(logging.INFO)
     with pytest.raises(ObjectCrudException):
         error_prone_backend_a.sync_from(backend_b)
 
@@ -211,9 +223,15 @@ def test_dsync_subclass_methods_sync_exceptions(caplog, error_prone_backend_a, b
     remaining_diffs.print_detailed()
     assert remaining_diffs.has_diffs()
 
+    # At least some operations of each type should have succeeded
+    assert log.has("Created successfully", status="success")
+    assert log.has("Updated successfully", status="success")
+    assert log.has("Deleted successfully", status="success")
     # Some ERROR messages should have been logged
-    assert [record.message for record in caplog.records if record.levelname == "ERROR"] != []
-    caplog.clear()
+    assert [event for event in log.events if event["level"] == "error"] != []
+    # Some messages with status="error" should have been logged - these may be the same as the above
+    assert [event for event in log.events if event.get("status") == "error"] != []
+    log.events = []
 
     # Retry up to 10 times, we should sync successfully eventually
     for i in range(10):
@@ -223,11 +241,15 @@ def test_dsync_subclass_methods_sync_exceptions(caplog, error_prone_backend_a, b
         remaining_diffs.print_detailed()
         if remaining_diffs.has_diffs():
             # If we still have diffs, some ERROR messages should have been logged
-            assert [record.message for record in caplog.records if record.levelname == "ERROR"] != []
-            caplog.clear()
+            assert [event for event in log.events if event["level"] == "error"] != []
+            # Some messages with status="errored" should have been logged - these may be the same as the above
+            assert [event for event in log.events if event.get("status") == "error"] != []
+            log.events = []
         else:
             # No error messages should have been logged on the last, fully successful attempt
-            assert [record.message for record in caplog.records if record.levelname == "ERROR"] == []
+            assert [event for event in log.events if event["level"] == "error"] == []
+            # Something must have succeeded for us to be done
+            assert [event for event in log.events if event.get("status") == "success"] != []
             break
     else:
         pytest.fail("Sync was still incomplete after 10 retries")