We often dump complex dictionaries in JSON format in log files. While most of the fields carry important information, we don't care much about the built-in class objects(for example a subprocess.Popen object). Due to presence of unserializable objects like these, call to json.dumps() fails.
To get around this, I built a small function that dumps object's string representation instead of dumping the object itself. And if the data structure you are dealing with is too nested, you can specify the nesting maximum level/depth.
from time import time
def safe_serialize(obj , max_depth = 2):
    max_level = max_depth
    def _safe_serialize(obj , current_level = 0):
        nonlocal max_level
        # If it is a list
        if isinstance(obj , list):
            if current_level >= max_level:
                return "[...]"
            result = list()
            for element in obj:
                result.append(_safe_serialize(element , current_level + 1))
            return result
        # If it is a dict
        elif isinstance(obj , dict):
            if current_level >= max_level:
                return "{...}"
            result = dict()
            for key , value in obj.items():
                result[f"{_safe_serialize(key , current_level + 1)}"] = _safe_serialize(value , current_level + 1)
            return result
        # If it is an object of builtin class
        elif hasattr(obj , "__dict__"):
            if hasattr(obj , "__repr__"):
                result = f"{obj.__repr__()}_{int(time())}"
            else:
                try:
                    result = f"{obj.__class__.__name__}_object_{int(time())}"
                except:
                    result = f"object_{int(time())}"
            return result
        # If it is anything else
        else:
            return obj
    return _safe_serialize(obj)
Since a dictionary can also have unserializable keys, dumping their class name or object representation will lead to all keys with same name, which will throw error as all keys need to have unique name, that is why the current time since epoch is appended to object names with int(time()).
This function can be tested with the following nested dictionary with different levels/depths-
d = {
    "a" : {
        "a1" : {
            "a11" : {
                "a111" : "some_value" ,
                "a112" : "some_value" ,
            } ,
            "a12" : {
                "a121" : "some_value" ,
                "a122" : "some_value" ,
            } ,
        } ,
        "a2" : {
            "a21" : {
                "a211" : "some_value" ,
                "a212" : "some_value" ,
            } ,
            "a22" : {
                "a221" : "some_value" ,
                "a222" : "some_value" ,
            } ,
        } ,
    } ,
    "b" : {
        "b1" : {
            "b11" : {
                "b111" : "some_value" ,
                "b112" : "some_value" ,
            } ,
            "b12" : {
                "b121" : "some_value" ,
                "b122" : "some_value" ,
            } ,
        } ,
        "b2" : {
            "b21" : {
                "b211" : "some_value" ,
                "b212" : "some_value" ,
            } ,
            "b22" : {
                "b221" : "some_value" ,
                "b222" : "some_value" ,
            } ,
        } ,
    } ,
    "c" : subprocess.Popen("ls -l".split() , stdout = subprocess.PIPE , stderr = subprocess.PIPE) ,
}
Running the following will lead to-
print("LEVEL 3")
print(json.dumps(safe_serialize(d , 3) , indent = 4))
print("\n\n\nLEVEL 2")
print(json.dumps(safe_serialize(d , 2) , indent = 4))
print("\n\n\nLEVEL 1")
print(json.dumps(safe_serialize(d , 1) , indent = 4))
Result:
LEVEL 3
{
    "a": {
        "a1": {
            "a11": "{...}",
            "a12": "{...}"
        },
        "a2": {
            "a21": "{...}",
            "a22": "{...}"
        }
    },
    "b": {
        "b1": {
            "b11": "{...}",
            "b12": "{...}"
        },
        "b2": {
            "b21": "{...}",
            "b22": "{...}"
        }
    },
    "c": "<Popen: returncode: None args: ['ls', '-l']>"
}
LEVEL 2
{
    "a": {
        "a1": "{...}",
        "a2": "{...}"
    },
    "b": {
        "b1": "{...}",
        "b2": "{...}"
    },
    "c": "<Popen: returncode: None args: ['ls', '-l']>"
}
LEVEL 1
{
    "a": "{...}",
    "b": "{...}",
    "c": "<Popen: returncode: None args: ['ls', '-l']>"
}
[NOTE]: Only use this if you don't care about serialization of a built-in class object.